Overall Statistics
Total Trades
49
Average Win
5.30%
Average Loss
-2.66%
Compounding Annual Return
12.054%
Drawdown
19.700%
Expectancy
1.620
Net Profit
168.337%
Sharpe Ratio
0.608
Sortino Ratio
0.479
Probabilistic Sharpe Ratio
16.214%
Loss Rate
12%
Win Rate
88%
Profit-Loss Ratio
1.99
Alpha
0.026
Beta
0.63
Annual Standard Deviation
0.119
Annual Variance
0.014
Information Ratio
-0.009
Tracking Error
0.091
Treynor Ratio
0.115
Total Fees
$49.00
Estimated Strategy Capacity
$74000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
1.50%
# ----< Global Imports >----
from AlgorithmImports import *
import numpy as np
import scipy
from scipy.signal import find_peaks
import pandas as pd
import timeit


def find_levels( 
        price: np.array, atr: float, # Log closing price, and log atr 
        first_w: float = 0.1, # The last price point has only 10% of the weight
        atr_mult: float = 1.0, 
        prom_thresh: float = 0.1
        ):

    # Setup weights
    last_w = 1.0 # the newest data has the most weight
    w_step = (last_w - first_w) / len(price)
    weights = first_w + np.arange(len(price)) * w_step
    weights[weights < 0] = 0.0

    # Get kernel of price. 
    kernal = scipy.stats.gaussian_kde(price, bw_method=atr*atr_mult, weights=weights)

    # Construct market profile
    min_v = np.min(price)
    max_v = np.max(price)

    step = (max_v - min_v) / 200
    price_range = np.arange(min_v, max_v, step)
    
    pdf = kernal(price_range) # Market profile

    # Find significant peaks in the market profile
    pdf_max = np.max(pdf)
    prom_min = pdf_max * prom_thresh

    peaks, props = scipy.signal.find_peaks(pdf, prominence=prom_min)
    levels = [] 
    for peak in peaks:
        levels.append(price_range[peak])

    return levels, peaks, props, price_range, pdf, weights


# ----< Global Imports >----
from AlgorithmImports import *
from harmonics import *
from levels import *
from trendlines import *


class JumpingBlackWhale(QCAlgorithm):

    def Initialize(self):

        # Backtest Settings
        self.SetStartDate(2015, 1, 1)
        self.SetEndDate(2023, 9, 1)
        self.SetCash(10000)
        self.lookback = 30

        # Instrument
        self.symbol = 'SPY'
        self.AddEquity(self.symbol, Resolution.Minute)

        # Indicators
        self.SMA = SimpleMovingAverage(self.symbol, 21)
        self.SMA_fast = SimpleMovingAverage(self.symbol, 20)
        self.STR = SuperTrend(20, 2, MovingAverageType.Wilders)
        self.ATR = AverageTrueRange(self.symbol, 20)

        # Consolidator
        self.cons = TradeBarConsolidator(timedelta(days=1))
        self.cons.DataConsolidated += self.consolidation_update
        self.SubscriptionManager.AddConsolidator(self.symbol, self.cons)

        # Register Indicator
        self.RegisterIndicator(self.symbol, self.SMA_fast, self.cons)
        self.RegisterIndicator(self.symbol, self.STR, self.cons)
        self.RegisterIndicator(self.symbol, self.ATR, self.cons)

        # Rolling Windows
        self.close_prices_window = RollingWindow[float](self.lookback)
        self.high_prices_window = RollingWindow[float](self.lookback)
        self.low_prices_window = RollingWindow[float](self.lookback)
        self.index_window = RollingWindow[DateTime](self.lookback)
        self.closest_levels = RollingWindow[float](self.lookback)

        # Initialize Modules
        self.trend = Trendline_Identification(self.lookback)
        self.harm = HarmonicsIdentification(self, 0.01)

        # Helpers
        self.series = Chart("Chart")
        self.circle = ScatterMarkerSymbol.Circle

        # Adding formatted series
        self.series.AddSeries(
            Series("Buy", SeriesType.Scatter, "$", Color.Green, self.circle)
            )
        self.series.AddSeries(
            Series("Sell", SeriesType.Scatter, "$", Color.Red, self.circle)
            )
        self.series.AddSeries(
            Series("Harmonic Sell", SeriesType.Scatter, "$", Color.Yellow, self.circle)
            )
        
        self.AddChart(self.series)

        
        self.warmup = 0

        self.quantity = 0
        self.stop = None
        self.bought = None
        self.sell_allowed = False

    
    def find_closest_number(self, array, x, atr):
        closest_number = float('-inf')

        new_array = []
        for i in array:
            new_array.append(i+atr)
        array = new_array
        
        for num in array:
            if num < x and num > closest_number:
                closest_number = num
        if closest_number == float('-inf'):
            return x
        return closest_number

    def OnData(self, data: Slice):
        pass

    def OnOrderEvent(self, orderEvent: object):
        self.quantity += orderEvent.FillQuantity
        if orderEvent.FillQuantity < 0:
            self.Plot("Chart", "Sell", orderEvent.FillPrice)

    def consolidation_update(self, sender: Any, bar: object) -> None:
        
        # Populate rolling windows
        self.index_window.Add(bar.EndTime)
        self.close_prices_window.Add(bar.Close)
        self.high_prices_window.Add(bar.High)
        self.low_prices_window.Add(bar.Low)

        self.Plot("Chart", "Price", bar.Close)

        if self.warmup > self.lookback:
        
            # ----< Initialize Market-View Modules >----
            list_index = []
            list_close = []
            list_low   = []
            list_high  = []

            # Convert rolling windows to lists
            for x in self.index_window:
                list_index.append(x)
            for x in self.close_prices_window:
                list_close.append(x)
            for x in self.low_prices_window:
                list_low.append(x)
            for x in self.high_prices_window:    
                list_high.append(x)
            
            # Convert all lists to pd.Series
            df_index = pd.Series(list_index[::-1])
            df_close = pd.Series(list_close[::-1])
            df_low   = pd.Series(list_low[::-1])
            df_high  = pd.Series(list_high[::-1])

            # Convert to one dataframe
            subset = pd.DataFrame({'High': df_high, 'Close': df_close, 'Low': df_low})
            subset.index = df_index

            # Modules
            pattern, best_fit_coefs, support_coefs, resist_coefs = self.trend.fit_trendlines(df_close)
            UTILS_, XABCD_, best_pat, ad = self.harm.harmonics(subset)
            levels, peaks, props, price_range, pdf, weights = find_levels(np.array(subset['Close']), self.ATR.Current.Value)
            self.harm.call_after_plotting(ad)

            self.closest_levels.Add(self.find_closest_number(levels, bar.Close, self.ATR.Current.Value))

            if best_pat and "Bullish" in best_pat:
                self.Plot("Chart", "Buy", bar.Close)
                self.SetHoldings(self.symbol, 1)
                self.sell_allowed = False
            
            if best_pat and "Bearish" in best_pat and "bullish" in pattern or "rising" in pattern:
                self.sell_allowed = True
                self.Plot("Chart", "Harmonic Sell", bar.Close)
                # self.SetHoldings(self.symbol, 0)
                
            if self.sell_allowed and self.stop and bar.Close < self.stop:
                self.SetHoldings(self.symbol, 0)
                self.Plot("Chart", "Sell", bar.Close)
                self.sell_allowed = False
            
            if self.stop:
                self.Plot("Chart", "stop", self.stop)
            
            if self.closest_levels.IsReady:
                self.stop = self.closest_levels[0]
                if self.closest_levels[1] < self.closest_levels[0]:
                    self.stop = self.closest_levels[1]
                if bar.Close < self.stop:
                    self.stop = self.ATR.Current.Value

        self.warmup = self.warmup + 1
# ----< Global Imports >----
from AlgorithmImports import *
from dataclasses import dataclass
from collections import deque
from typing import Union
import pandas as pd
import numpy as np
import itertools


class HarmonicsIdentification():

    @dataclass
    class XABCD_RATIOS:

        """
        Description:
            A data class for holding information about the pattern
        """

        XB: Union[float, list, None]
        AC: Union[float, list, None]
        DB: Union[float, list, None]
        XD: Union[float, list, None]
        name: str

    @dataclass
    class XABCD:

        """
        Description:
            A data class which holds information of the current pattern, its values
        """
        
        # Index on the chart, that is accepted by mplfinance
        # If there currently is 100 candles on the chart, and D is the
        # last data point, then D will be 100.
        X: float
        D: float
        C: float
        B: float
        A: float

        # Prices of each point
        X_price: float
        D_price: float
        C_price: float
        B_price: float
        A_price: float

        # Retracement ratios between key points
        DB_ratio: float
        AC_ratio: float
        XB_ratio: float
        XD_ratio: float

        def __init__(self):
            
            # Reset Variables with each initialization
            self.X = None
            self.D = None
            self.C = None
            self.B = None
            self.A = None

            self.X_price = None
            self.D_price = None
            self.C_price = None
            self.B_price = None
            self.A_price = None

            self.DB_ratio = None
            self.AC_ratio = None
            self.XB_ratio = None
            self.XD_ratio = None

    @dataclass
    class UTILS:

        """
        Description:
            A data class which holds data that is used for plotting
        """
        
        bottoms_ratios: list # List of indexes for the bottom points
        tops_ratios:    list # List of indexes for the upper points
        
        # Tuples that are accepted by mplfinance for plotting a tline
        indexes_pairs:        tuple
        top_ratios_pairs:     tuple  
        bottoms_ratios_pairs: tuple 
        xd_line:              tuple 

        def __init__(self):

            # Reset Variables with each initialization        
            self.bottoms_ratios = deque(maxlen=3)  
            self.tops_ratios    = deque(maxlen=3) 
            
            self.indexes_pairs        = None  
            self.top_ratios_pairs     = None  
            self.bottoms_ratios_pairs = None 
            self.xd_line              = None 


    def __init__(self, algo, zigzag_sigma: float, err_thresh: float = 1.2) -> None:
        
        """
        Description:
            This module identifies if there currently is a harmonics pattern visible in a given set of data

        Arguments:
            *zigzag_sigma: a percentage change in price which must occur for the zigzag to record a top or a bottom

        """

        self.algo = algo
        self.zigzag_sigma = zigzag_sigma
        self.indexes = deque(maxlen=5)
        self.err_thresh = err_thresh

        # Define Patterns
        self.GARTLEY = self.XABCD_RATIOS(0.618, [0.382, 0.886], [1.13, 1.618], 0.786, "Gartley")
        self.BAT = self.XABCD_RATIOS([0.382, 0.50], [0.382, 0.886], [1.618, 2.618], 0.886, "Bat")
        self.BUTTERFLY = self.XABCD_RATIOS(0.786, [0.382, 0.886], [1.618, 2.24], [1.27, 1.41], "Butterfly")
        self.CRAB = self.XABCD_RATIOS([0.382, 0.618], [0.382, 0.886], [2.618, 3.618], 1.618, "Crab")
        self.DEEP_CRAB = self.XABCD_RATIOS(0.886, [0.382, 0.886], [2.0, 3.618], 1.618, "Deep Crab")
        self.CYPHER = self.XABCD_RATIOS([0.382, 0.618], [1.13, 1.41], [1.27, 2.00], 0.786, "Cypher")
        self.ALL_PATTERNS = [self.GARTLEY, self.BAT, self.BUTTERFLY, self.CRAB, self.DEEP_CRAB, self.CYPHER]

    def zigzag(self, close: np.array, high: np.array, low: np.array):
        
        """ 
        Description:
            Function is a zigzag indicator which draws ups and downs of the market.
            For each turning point to occur price must move at least the % amount of sigma
        
        Arguments:
            *close: np.array of close prices
            *high: np.array of close prices
            *low: np.array of close prices
        
        Returns:
            tops: a list of all identified tops in the given set of data
            bottoms: a list of all identified tops in the given set of data
        """

        up_zig = True # Last extreme is a bottom. Next is a top. 
        tmp_max = high[0]
        tmp_min = low[0]
        tmp_max_i = 0
        tmp_min_i = 0

        tops = []
        bottoms = []

        for i in range(len(close)):
            if up_zig: # Last extreme is a bottom
                if high[i] > tmp_max:
                    # New high, update 
                    tmp_max = high[i]
                    tmp_max_i = i
                elif close[i] < tmp_max - tmp_max * self.zigzag_sigma: 
                    # Price retraced by sigma %. Top confirmed, record it
                    # top[0] = confirmation index
                    # top[1] = index of top
                    # top[2] = price of top
                    top = [i, tmp_max_i, tmp_max]
                    tops.append(top)

                    # Setup for next bottom
                    up_zig = False
                    tmp_min = low[i]
                    tmp_min_i = i
            else: # Last extreme is a top
                if low[i] < tmp_min:
                    # New low, update 
                    tmp_min = low[i]
                    tmp_min_i = i
                elif close[i] > tmp_min + tmp_min * self.zigzag_sigma: 
                    # Price retraced by sigma %. Bottom confirmed, record it
                    # bottom[0] = confirmation index
                    # bottom[1] = index of bottom
                    # bottom[2] = price of bottom
                    bottom = [i, tmp_min_i, tmp_min]
                    bottoms.append(bottom)

                    # Setup for next top
                    up_zig = True
                    tmp_max = high[i]
                    tmp_max_i = i

        return tops, bottoms


    def get_error(self, required_ratio: Union[float, list], current_ratio: float) -> bool:

        """
        Description:
            Function checks the error between the required ratio, which is found in
            pattern definitions and the current ratio, found in the market from one zigzag
            point to another.

        Arguments:
            *required_ratio: Union[float, list] Is a ratio that is required for the given pattern
                if required ratio is a list, the lenght must be equal to 2
            *current_ratio: float Is a ratio that is currently observed in the market between given zigzag points

        Returns:
            The absolute difference between two ratios. If required ratio is a list, then returns the smaller error from them
        """

        if isinstance(required_ratio, list):
            
            # If ratio is a list where one of the values are None, then only compare the value thats not None
            if required_ratio[0] is None:
                return abs(required_ratio[0]-current_ratio)
            elif required_ratio[1] is None:
                return abs(required_ratio[1]-current_ratio)
            else:
                ValueError('Required Ratio in get_error function is all None')
            
            # If current ratio falls between the bounds, the error is 0
            if min([required_ratio[0], required_ratio[1]]) < current_ratio and current_ratio < max([required_ratio[0], required_ratio[1]]):
                return 0
            # Otherwise return the minimum error from the difference of the closest bound
            else:
                return min([abs(required_ratio[0]-current_ratio), abs(required_ratio[1]-current_ratio)])
        
        # If required ratio for that data point is None, then there is no error, thus return 0
        if required_ratio is None:
            return 0
        
        # In all other cases return the absolute difference between ratios
        return abs(current_ratio-required_ratio)

    def call_after_plotting(self, added_last_value):
        if added_last_value:
            self.indexes.pop()
            added_last_value = False


    def harmonics(self, subset: pd.DataFrame, return_tuples: bool = False):
        
        XABCD_ = self.XABCD()
        UTILS_ = self.UTILS()

        close = subset['Close']
        high = subset['High']
        low = subset['Low']

        ad = False

        tops, bottoms = self.zigzag(np.array(close), np.array(high), np.array(low))

        if len(tops) > 0 and len(bottoms) > 0 and len(subset) > 0:

            # Index of the last top and the last bottom
            tops_date = subset.iloc[tops[-1][1]].name
            bottoms_date = subset.iloc[bottoms[-1][1]].name

            # if this last top/bottom is currently not recorded- record it
            self.indexes.append(tops_date) if tops_date not in self.indexes else None
            self.indexes.append(bottoms_date) if bottoms_date not in self.indexes else None
            
            # Also add the last bar
            if (subset.iloc[-1].name) not in self.indexes:
                self.indexes.append((subset.iloc[-1].name))
                ad = True

            # Show ratio (retracement) lines from peaks of zigzag
            for l, top in enumerate(tops):
                top_date = subset.iloc[tops[l][1]].name
                if top_date not in UTILS_.tops_ratios:
                    if len(UTILS_.tops_ratios) > 0 and top_date > UTILS_.tops_ratios[-1]:
                        UTILS_.tops_ratios.append(top_date)
                    elif len(UTILS_.tops_ratios) == 0:
                        UTILS_.tops_ratios.append(top_date)
        
            for l, bottom in enumerate(bottoms):
                bottom_date = subset.iloc[bottoms[l][1]].name
                if bottom_date not in UTILS_.bottoms_ratios:
                    if len(UTILS_.bottoms_ratios) > 0 and bottom_date > UTILS_.bottoms_ratios[-1]:
                        UTILS_.bottoms_ratios.append(bottom_date)
                    elif len(UTILS_.bottoms_ratios) == 0:
                        UTILS_.bottoms_ratios.append(bottom_date)
                
            # Extent ratio line to the very last candle
            if UTILS_.tops_ratios[-1] < UTILS_.bottoms_ratios[-1]:
                if subset.iloc[-1].name not in UTILS_.tops_ratios:
                    UTILS_.tops_ratios.append(subset.iloc[-1].name)
                UTILS_.bottoms_ratios.popleft()
            else:
                if subset.iloc[-1].name not in UTILS_.bottoms_ratios:
                    UTILS_.bottoms_ratios.append(subset.iloc[-1].name)
                UTILS_.tops_ratios.popleft()

            all_candles = len(subset)

            # Get the index of each point (DateTime)
            D_index = self.indexes[-1] if len(self.indexes) > 0 else None
            C_index = self.indexes[-2] if len(self.indexes) > 1 else None
            B_index = self.indexes[-3] if len(self.indexes) > 2 else None
            A_index = self.indexes[-4] if len(self.indexes) > 3 else None
            X_index = self.indexes[-5] if len(self.indexes) > 4 else None

            XABCD_.D = subset.loc[D_index]
            XABCD_.C = (subset.index.get_loc(C_index)+1) if (C_index and C_index in subset.index) else None
            XABCD_.B = (subset.index.get_loc(B_index)+1) if (B_index and B_index in subset.index) else None
            XABCD_.A = (subset.index.get_loc(A_index)+1) if (A_index and A_index in subset.index) else None
            XABCD_.X = (subset.index.get_loc(X_index)+1) if (X_index and X_index in subset.index) else None

            # Fetch its price
            XABCD_.D_price = close[-1]
            if XABCD_.C:
                XABCD_.C_price = subset['Low' if (XABCD_.D_price > close[-int(all_candles-XABCD_.C+1)]) else 'High'][-int(all_candles-XABCD_.C+1)]
            else:
                XABCD_.C_price = None
            
            if XABCD_.B and XABCD_.C:
                XABCD_.B_price = subset['Low' if (XABCD_.D_price < close[-int(all_candles-XABCD_.C+1)]) else 'High'][-int(all_candles-XABCD_.B+1)]
            else:
                XABCD_.B_price = None
            
            if XABCD_.A and XABCD_.C:
                XABCD_.A_price = subset['Low' if (XABCD_.D_price > close[-int(all_candles-XABCD_.C+1)]) else 'High'][-int(all_candles-XABCD_.A+1)]
            else:
                None
            
            if XABCD_.X and XABCD_.C:
                XABCD_.X_price = subset['Low' if (XABCD_.D_price < close[-int(all_candles-XABCD_.C+1)]) else 'High'][-int(all_candles-XABCD_.X+1)]
            else:
                XABCD_.X_price = None

            DC_h = abs(XABCD_.D_price - XABCD_.C_price) if (XABCD_.D_price and XABCD_.C_price) else None
            CB_h = abs(XABCD_.C_price - XABCD_.B_price) if (XABCD_.C_price and XABCD_.B_price) else None
            BA_h = abs(XABCD_.B_price - XABCD_.A_price) if (XABCD_.B_price and XABCD_.A_price) else None
            AX_h = abs(XABCD_.A_price - XABCD_.X_price) if (XABCD_.A_price and XABCD_.X_price) else None

            XABCD_.DB_ratio = (DC_h / CB_h) if CB_h else None
            XABCD_.AC_ratio = (CB_h / BA_h) if BA_h else None
            XABCD_.XB_ratio = (BA_h / AX_h) if AX_h else None
            try:
                XABCD_.XD_ratio = (AX_h / DC_h) if AX_h else None
            except:
                XABCD_.XD_ratio = 0
            
            best_err = 1e30
            best_pat = None
            if XABCD_.DB_ratio and XABCD_.AC_ratio and XABCD_.XB_ratio and XABCD_.XD_ratio:

                for pat in self.ALL_PATTERNS:
                    err = 0.0
                    err += self.get_error(pat.DB, XABCD_.DB_ratio)
                    err += self.get_error(pat.AC, XABCD_.AC_ratio)
                    err += self.get_error(pat.XB, XABCD_.XB_ratio)
                    err += self.get_error(pat.XD, XABCD_.XD_ratio)
    
                    if err < best_err:
                        best_err = err
                        best_pat = pat.name
                
                if best_err <= self.err_thresh:
                    if XABCD_.D_price > XABCD_.C_price:
                        best_pat = f'Bearish {best_pat}'
                    else:
                        best_pat = f'Bullish {best_pat}'
                else:
                    best_pat = None
            
            if not return_tuples:
                return UTILS_, XABCD_, best_pat, ad
            else:
                if len(self.indexes) > 1:

                    UTILS_.indexes_pairs = list(zip(self.indexes, itertools.islice(self.indexes, 1, None)))
                    UTILS_.top_ratios_pairs = list(zip(UTILS_.tops_ratios, itertools.islice(UTILS_.tops_ratios, 1, None)))
                    UTILS_.bottoms_ratios_pairs = list(zip(UTILS_.bottoms_ratios, itertools.islice(UTILS_.bottoms_ratios, 1, None)))
                    UTILS_.xd_line = list(zip([self.indexes[0]], [self.indexes[-1]]))
                
                    return UTILS_, XABCD_, best_pat, ad
                else:
                    return UTILS_, XABCD_, best_pat, ad
        
        if not return_tuples:
            return UTILS_, XABCD_, None, ad
        else:
            return UTILS_, XABCD_, None, ad
# ----< Global Imports >----
from AlgorithmImports import *
import numpy as np
from scipy.optimize import minimize, LinearConstraint
import pandas as pd
import timeit


class Trendline_Identification():

    """
    Class draws support and resistance lines

    Please call fit_trendlines() function which returns coefficients:
    *** coefficient[0] = slope
    *** coefficient[1] = intercept

    Build a trendline with "y = slope * x + intercept" function
    """

    def __init__(self, total_candles_visible: int):
        
        self.pattern = None
        self.total = total_candles_visible

    def check_trend_line(self, support: bool, pivot: int, slope: float, y: np.array):
        # Compute sum of differences between line and prices
        # return negative val if invalid

        # Find the intercept of the line going through pivot point with given slope
        intercept = -slope * pivot + y[pivot]
        line_vals = slope * np.arange(len(y)) + intercept
        diffs = line_vals - y

        # Check to see if the line is valid, return -1 if it is not valid
        if support and diffs.max() > 1e-5:
            return -1.0
        elif not support and diffs.min() < -1e-5:
            return -1.0
        
        # Squared sum of diffs between data and line
        err = (diffs ** 2.0).sum()
        return err

    def optimize_slope(self, support: bool, pivot: int, init_slope: float, y: np.array):

        # Amount to change slope by. Multiplyed by opt_step
        slope_unit = (y.max() - y.min()) / len(y)

        # Optimization variables
        opt_step = 1.0
        min_step = 0.001
        curr_step = opt_step

        # Initiate at the slope of the line of best fit
        best_slope = init_slope
        best_err = self.check_trend_line(support, pivot, init_slope, y)
        assert(best_err >= 0.0)

        get_derivative = True
        derivative = None
        while curr_step > min_step:

            if get_derivative:
                # Numerical differentiation, increase slope by very small amount
                # to see if error increases/decreases
                # Gives us the direction to change slope
                slope_change = best_slope + slope_unit * min_step 
                test_err = self.check_trend_line(support, pivot, slope_change, y)
                derivative = test_err - best_err
        
                # If increasing by a small amount fails,
                # try decreasing by a small amount
                if test_err < 0.0:
                    slope_change = best_slope - slope_unit * min_step

                    test_err = self.check_trend_line(support, pivot, slope_change, y)
                    derivative = best_err - test_err
                
                if test_err < 0.0: # Derivative failed, give up
                    raise Exception("Derivative failed. Check your data")
                
                get_derivative = False

            if derivative > 0.0: # Increasing slope increased error
                test_slope = best_slope - slope_unit * curr_step
            else: # Increasing slope decreased error
                test_slope = best_slope + slope_unit * curr_step

            test_err = self.check_trend_line(support, pivot, test_slope, y)

            if test_err < 0 or test_err >= best_err:
                # slope failed/didn't reduce error
                curr_step *= 0.5 # Reduce step size
            else: # Test slope reduced error
                best_err = test_err
                best_slope = test_slope
                get_derivative = True
            
        # Optimize done, return best slope and intercept
        return (best_slope, -best_slope * pivot + y[pivot])

    def fit_trendlines(self, close: np.array, high: np.array = None, low: np.array = None):
        
        X = np.arange(len(close))
        best_fit_coefs = np.polyfit(X, close, 1)
        line_points = best_fit_coefs[0] * X + best_fit_coefs[1]

        upper_pivot = ((high if high is not None else close) - line_points).argmax()
        lower_pivot = ((low if low is not None else close)- line_points).argmin()


        # Optimize the slope for both trend lines
        support_coefs = self.optimize_slope(True, lower_pivot, best_fit_coefs[0], (low if low is not None else close))
        resist_coefs  = self.optimize_slope(False, upper_pivot, best_fit_coefs[0], (high if high is not None else close))

        # If intercept positive, then trendlines will intercept in the future. If negative, they have already inrecepted
        intercept_x = ((support_coefs[1] - resist_coefs[1]) / (support_coefs[0] - resist_coefs[0])) - self.total + 1

        if support_coefs[0] > 0 and resist_coefs[0] > 0:
            # We know its a bullish range upwards
            if intercept_x > 0 and intercept_x < self.total:
                self.pattern = 'rising wedge'
            if intercept_x < 0 and abs(intercept_x) < self.total:
                self.pattern = 'expanding range'
            else:
                self.pattern = 'bullish channel'
        
        if support_coefs[0] < 0 and resist_coefs[0] < 0:
            # We know its a bearish range downwards
            if intercept_x > 0 and intercept_x < self.total:
                self.pattern = 'falling wedge'
            if intercept_x < 0 and abs(intercept_x) < self.total:
                self.pattern = 'expanding range'
            else:
                self.pattern = 'bearish channel'

        if support_coefs[0] > 0 and resist_coefs[0] < 0:
            if intercept_x > 0 and intercept_x < self.total:
                self.pattern = 'triangle'
            else:
                self.pattern = 'range'
        
        if support_coefs[0] < 0 and resist_coefs[0] > 0:
            self.pattern = 'range'

        return (self.pattern, best_fit_coefs, support_coefs, resist_coefs)