Day Trading Reversals with Python | by Amal Tyagi | Sep, 2024 | Medium

Source: Day Trading Reversals with Python | by Amal Tyagi | Sep, 2024 | Medium

Amal Tyagi

Amal Tyagi

15 min read

Sep 12, 2024

(Read with no paywall here.)

First off—my apologies for the slowdown in Medium posts lately. I’m in the process of building a data collection engine for real-time option chains and price data provided by Fidelity, and I’m in between several books on trading and investment strategy. One of these books, “How to Day Trade for a Living” by Andrew Aziz, heavily inspired the code I’ll share today.

I’ve recently written a script which identifies potential intraday reversals, specifically candlestick indecision patterns for day trading stocks. We can detect reversals in any price action, as I explored in a previous article on mean reversion, but today’s strategy is for making short-term day trades when breaks in strong trends are confirmed by large relative volume. So while my mean reversion code in the past was focused more on medium- or long-term swing trades, Aziz’s strategy is to make very brief day trades on very strict conditions. We likely won’t beat the market with simple short-term candlestick detection but this may be another piece in the puzzle!

Aziz’s books are helpful for those looking to explore a broad range of strategies which include: trading gaps at the open, following VWAP and other trendlines, and identifying potential intraday reversals. He emphasizes the importance of a disciplined day trading strategy with pre-defined profit/loss targets, proper risk management (e.g. no more than 1-2% of a portfolio risked on any trade), and personalization geared towards each individual. Basically, there’s room in the market for everyone and that includes algorithmic traders like ourselves.

Let’s get into the strategy and related Python code.

What Are RSI Reversals?

Relative strength index (RSI) is one of the most powerful tools used by traders and it’s something you’ve probably already seen before. This momentum indicator is calculated by recursively comparing the average gain to the average loss for a given time period, like this:

RSI formula for 14 periods (Source: Investopedia).

This is what RSI looks like on a chart. Notice that, regardless of a price’s long-term trend, RSI shows the cyclical nature of price movement by standardizing it to the range 0 to 100.

Let’s further inspect the chart. A common interpretation is that RSI shows “overbought” periods when it reaches around 70 or 80, and “oversold” periods when it falls below 20 or 30. But simply buying when the RSI is low isn’t necessarily a good idea as the price could continue falling beyond this point before finally turning around. Similarly, we don’t want to sell right when the RSI enters an overbought period because, chances are, the price will continue increasing for awhile and we’ll miss out on further gains.

This volatility even at extreme RSI levels has somewhat baffled me (and my trader friends) in the pursuit of trading algorithms. Aziz’s suggestion to handle this is to only check for reversals at very extreme levels like 10 and 90 to represent oversold and overbought conditions, and to then wait for a test confirmation in the next candle. By waiting for extremely high or low RSI values, there’s less of a chance that a current trend will continue for much longer—and if it does seem to continue, we won’t trade that trend.

Identifying Potential Reversals

The rest of the strategy is as follows:

  • Use candlestick charts to find strong trends which may weaken soon. Look for 4 or more consecutive candles in a single direction (i.e. 4 red candles or 4 green ones in a row), and check that RSI < 10 or RSI > 90.
  • A trend may be signaling a reversal when an indecision candle (like a doji, hammer, or shooting star) occurs after 4+ consecutive candles. There is also a chance that indecision may not be reflected on your chart, and instead we may see a large candle in the opposite direction of the trend. So far I’ve only coded the three indecision candles pictured below, along with bullish/bearish engulfing candles, into my reversal detection. You’re free to add in any other counter-trend candles which you see fit, and it also might be helpful to adjust my definitions of these patterns as needed since the book doesn’t list any formulas.
  • Relative volume is used extensively in Aziz’s book, but the exact conditions are not really specified for this strategy. The idea, though, is that we should trade reversals at times of high relative volume. Aziz appears to use a simple ratio like the one below and then looks for values of at least 1. Often we should find that the higher the relative volume, the better our chances in trading a reversal.
# Calculate current candle's volume against today's average volume
avg_vol_today = data['Volume'].mean()
rel_vol = vol / avg_vol_today
  • Average True Range (ATR) is a volatility indicator used in this strategy. It can be used to identify and account for price fluctuations in both short- and long-term trading decisions. There are several uses for it mentioned in the book, but here we’ll use it to compare a candlestick’s body with its wicks in our identification of the patterns pictured above. (We might also use it to set stop losses and profit targets for our trades.) To calculate it, we’ll need the True Range, which measures the highest price movement between a candlestick’s high, low, and previous close.

I’ve struggled to predict RSI reversals in the past but watching the price action after these specific candlestick patterns seem to answer some questions I’ve had about test confirmation. Indecision patterns like hammers and shooting stars are clear short-term indicators of who (between buyers and sellers) currently has control over an asset’s price, and candlesticks represent this action better than line charts by displaying the full range of prices, rather than just each interval’s closing price.

So shooting stars can be seen as a “losing battle” of buyers as they try and fail to push up the price, and conversely, hammers show a losing battle of sellers as they try to push down the price. And according to Aziz, we can confirm this price action by looking for overextended RSI levels and high volume, and make our trade in or after the following candle.

Trading Reversals

The patterns described above—and today’s code—will indicate potential points of trend reversal for day trading. But actually trading the results of this stock screen will require that you decide on entry and exit points in your own trading strategy. Aziz’s “algorithm” is essentially to take information from a stock screen and then view the chart manually to check for test confirmation.

He generally suggests that we wait until candles are closed to decide on an entry/exit, but this means that a reversal may occur too quickly and we might decrease our profit potential while increasing risk. But for our algorithmic trading purposes we have total flexibility, so do whatever makes you feel the most comfortable. yfinance will return any current candle in addition to the already-closed ones so we are able to, for example, check price action within a single 5-min candle. (Note that there is often a delay of around 1 minute when fetching prices with yfinance, so for 1-min or other short-term trading decisions it might be best to head directly to your broker to check price action in the current candle.)

Potential entry points occur when we see confirmation of our reversal strategy, i.e. formation of a reversal candle with extreme RSI and volume followed by a new 1-min or 5-min high in the next candle.

Deciding on an exit may be the most difficult aspect of day trading so be sure to backtest any theories before executing day trades on your platform. For example, consider setting a stop loss at the low of a previous red candlestick or the low of the day for bottom reversals, or do the opposite for top reversals. Our profit target, then, might be reached when price is at the next level of support (or resistance for top reversals), or when a trendlines like VWAP and short term EMAs are tested… or simply when price hits a 5-min low in a new candle (or a new high for bottom reversals).

For example, below we can clearly see a sort of shooting star pattern forming with high relative volume, indicating that the previous uptrend is coming to an end. The following candle doesn’t engulf the previous one but it is a large counter-trend candle which forms a new relative low, and using the previous candle to set a stop loss would have been effective for handling this price action. Interestingly, we can also use our code to determine when the bearish trend that follows comes to an end. The image to the right shows that we’ve calculated a near-perfect exit point for our trade at the marked hammer (or, loosely, a doji/hammer hybrid) which is confirmed by low RSI, high ATR, and high relative volume.

Reversals successfully detected with large counter-trend candles signaling the end of previous trends.

We won’t find indecision patterns on both sides of every trade, though, especially when we set “magic numbers” like RSI = 10 or 90 which fundamentally say nothing about a stock’s value except that it may be overextended. This is why it’s important to test a variety of strategies on your data before trading. Let’s take a look at some starter code which you can use in your backtests!

The Code

First you’ll need to import necessary dependencies. The initial Selenium and webdriver_manager imports are only necessary if you’re looking to scrape all ~10k or so public tickers from SEC.gov. Feel free to use your own stock watchlist and update fetch_tickers() as needed.

Notice that I’ve configured the timezone environment variable (market_tz) for consistency. I’ve also suppressed warnings which occur as a result of empty data, either within our script and internally in yfinance. To see this feedback, leave out the warnings and logging lines from your imports.

from selenium import webdriver
from selenium.webdriver.firefox.service import Service as FirefoxService
from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.firefox import GeckoDriverManager
import pandas as pd
from datetime import datetime, timedelta
import pytz
market_tz = pytz.timezone("America/New_York")
from time import sleep
import os
import json
import yfinance as yf
import numpy as np
import warnings
warnings.filterwarnings("ignore")
import logging
logging.getLogger("yfinance").setLevel(logging.CRITICAL)

# Load stock symbols
def fetch_tickers():
    today_date = datetime.now().strftime('%Y-%m-%d')
    file_name = f"sec_tickers_{today_date}.json"
    file_path = os.path.join('tickers', file_name)
    
    # Check if tickers file exists for current date
    if os.path.exists(file_path):
        print(f"Loading tickers from {file_path}...")
        with open(file_path, 'r') as file:
            data = json.load(file)
        tickers = [item["ticker"].replace('/', '-') for item in data.values()]
        print(f"{len(tickers)} tickers loaded from today's stored tickers/ file.")
        return tickers
    
    # If file does not exist, proceed with fetching using Selenium
    print('Fetching tickers from SEC using Selenium...')
    url = "https://www.sec.gov/files/company_tickers.json"
    firefox_options = FirefoxOptions()
    firefox_options.add_argument('--headless')
    driver = webdriver.Firefox(service=FirefoxService(GeckoDriverManager().install()), options=firefox_options)
    driver.get(url)
    raw_button = WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.XPATH, "//a[contains(text(), 'Raw')]")))
    raw_button.click()
    WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.TAG_NAME, "pre")))
    sec_text = driver.find_element(By.TAG_NAME, "pre").text
    driver.close()
    data = json.loads(sec_text)
    tickers = [item["ticker"].replace('/', '-') for item in data.values()]
    print(f'{len(tickers)} fetched from SEC')

    # Save the JSON data for future use
    os.makedirs('tickers', exist_ok=True)
    with open(file_path, 'w') as file:
        json.dump(data, file)
    print(f'Tickers data saved to {file_path}')
    return tickers

Next we have some basic helper functions to compute Average True Range (ATR) and Relative Strength Index (RSI) for the past 14 periods.

# ATR calculation function
def calculate_atr(data, period=14):
    high = data['High']
    low = data['Low']
    close = data['Close']

    # True Range (TR) calculations
    high_low = high - low
    high_close = np.abs(high - close.shift(1))
    low_close = np.abs(low - close.shift(1))

    # Use np.maximum to calculate element-wise maximum for true range
    tr = np.maximum(high_low, np.maximum(high_close, low_close))

    # Average True Range (ATR) is the rolling mean of the True Range
    atr = tr.rolling(window=period).mean()
    return atr.iloc[-1].item()  # Return the most recent ATR value

# RSI calculation function
def calculate_rsi(data, period=14):
    delta = data['Close'].diff()
    gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
    loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
    rs = gain / loss
    rsi = 100 - (100 / (1 + rs))
    return rsi

The main function in our backtest is check_reversal(), which uses the conditions described above to print out potential tops and bottoms for any given price data. Consider adjusting the default parameters—like min_consecutive_candles = 4, wick_multiplier = 1.5, and so on—based on your needs. For example, you might try to look for just 3 consecutive red or green candles and slightly less extreme RSI levels.

# Function to check for potential reversals
def check_reversal(data, ticker, interval, min_consecutive_candles=4, wick_multiplier=1.5, lower_rsi=10, upper_rsi=90, min_rel_vol_vs_day=1, min_rel_vol_vs_5_prev=1):
    atr = calculate_atr(data)
    rsi = calculate_rsi(data).iloc[-1]  # Most recent RSI

    if lower_rsi < rsi < upper_rsi:
        return None

    last_candle = data.iloc[-1]
    prev_candle = data.iloc[-2]
    open_price = last_candle['Open']
    close_price = last_candle['Close']
    high_price = last_candle['High']
    low_price = last_candle['Low']
    vol = last_candle['Volume']

    avg_vol_today = data['Volume'].mean()
    rel_vol_vs_day = vol / avg_vol_today
    avg_vol_last_5 = data['Volume'].iloc[-6:-1].mean()
    rel_vol_vs_5_prev = vol / avg_vol_last_5

    rel_vol_vs_day_check, rel_vol_vs_5_prev_check = False, False
    if rel_vol_vs_day > min_rel_vol_vs_day:
        rel_vol_vs_day_check = True
    if rel_vol_vs_5_prev > min_rel_vol_vs_5_prev:
        rel_vol_vs_5_prev_check = True
    if not (rel_vol_vs_day_check or rel_vol_vs_5_prev_check):
        return None

    body_range = np.abs(close_price - open_price)
    upper_wick = np.abs(high_price - max(close_price, open_price))
    lower_wick = np.abs(min(close_price, open_price) - low_price)
    candle_type = None

    if body_range < atr:
        if upper_wick > atr * wick_multiplier:
            if lower_wick > atr * wick_multiplier: 
                candle_type = "doji"
            else:
                candle_type = "star"
        elif lower_wick > atr * wick_multiplier:
            candle_type = "hammer"
    elif (last_candle['Close'] > last_candle['Open']) and (prev_candle['Close'] < prev_candle['Open']) and \
            (last_candle['Open'] < prev_candle['Close']) and (last_candle['Close'] > prev_candle['Open']):
        if body_range > atr:
            candle_type = "bullish engulfing"
    elif (last_candle['Close'] < last_candle['Open']) and (prev_candle['Close'] > prev_candle['Open']) and \
            (last_candle['Open'] > prev_candle['Close']) and (last_candle['Close'] < prev_candle['Open']):
        if body_range > atr:
            candle_type = "bearish engulfing"

    if not candle_type:
        return None

    def count_consecutive_candles(data, direction="green"):
        count = 0
        for i in range(1, len(data)):
            current_candle = data.iloc[-i]
            if direction == "green" and current_candle['Close'] > current_candle['Open']:
                count += 1
            elif direction == "red" and current_candle['Close'] < current_candle['Open']:
                count += 1
            else:
                break
        return count

    if candle_type in ["doji", "star", "bearish engulfing"]:
        green_candle_count = count_consecutive_candles(data, "green")
        if green_candle_count >= min_consecutive_candles and rsi > upper_rsi:
            candle_time = last_candle.name
            print(f"{candle_time}: Potential {interval} top for {ticker} at {close_price:.2f} after {green_candle_count} green candles, {candle_type}, RSI {rsi:.2f}, high vol vs. {'day' if rel_vol_vs_day_check else ''} {'and' if rel_vol_vs_day_check and rel_vol_vs_5_prev_check else ''} {'prev 5' if rel_vol_vs_5_prev_check else ''}")
    if candle_type in ["doji", "hammer", "bullish engulfing"]:
        red_candle_count = count_consecutive_candles(data, "red")
        if red_candle_count >= min_consecutive_candles and rsi < lower_rsi:
            candle_time = last_candle.name
            print(f"{candle_time}: Potential {interval} bottom for {ticker} at {close_price:.2f} after {red_candle_count} red candles, {candle_type}, RSI {rsi:.2f}, high vol vs. {'day' if rel_vol_vs_day_check else ''} {'and' if rel_vol_vs_day_check and rel_vol_vs_5_prev_check else ''} {'prev 5' if rel_vol_vs_5_prev_check else ''}")

Finally, we can use a simple for-loop (or, with some adjustment, a parallelized Spark process) to run a backtest on our selected tickers:

# Fetch max days of data for given interval
def get_last_max_data(ticker, interval='1m'):
    max_days = {'1m': '28d', '5m': '60d'}
    if interval == '1m':
        today = datetime.now(pytz.timezone('US/Eastern'))
        end_date = today.replace(hour=4, minute=0, second=0, microsecond=0) + timedelta(days=1)
        start_date = end_date - timedelta(days=7)
        ticker = yf.Ticker(ticker)
        data = pd.DataFrame()
        for i in range(4):
            premarket_data = ticker.history(start=start_date, end=end_date, interval='1m', prepost=True)
            premarket_data['Datetime'] = premarket_data.index
            data = pd.concat([data, premarket_data])
            end_date = start_date
            if i > 2:
                start_date = end_date - timedelta(days=6)
            else:
                start_date = end_date - timedelta(days=7)

        data.index = data.index.tz_localize(None)
        data.sort_index(inplace=True)
    else:
        data = yf.download(ticker, period=max_days[interval], interval=interval, prepost=True, progress=False)
    data.index = pd.to_datetime(data.index)
    return data

# Run reversal check on historical data for a single ticker (e.g., TSLA)
def run_on_historical_data(ticker, interval='1m'):
    #print(f"Running reversal check on {ticker} for {interval} historical data...")
    prices = get_last_max_data(ticker, interval=interval)
    prices_clean = prices.dropna()
    if prices_clean.empty:
        #print(f"No data available for {ticker}.")
        return
    
    # Iterate over each chunk of data (e.g., per minute) to simulate reversal checks
    for i in range(15, len(prices_clean)):  # Start after enough candles to calculate indicators
        chunk = prices_clean.iloc[:i]  # Slice the dataframe up to the current point, excluding the current point
        check_reversal(chunk, ticker, interval)

# Main execution for historical data
tickers = fetch_tickers()
max_days = {'1m': '7d', '5m': '60d'}
intervals = max_days.keys()
for ticker in tickers[:5000]:
    for interval in intervals:
        run_on_historical_data(ticker, interval=interval)

Results

Your resulting output might look something like this:

2024-09-03 14:08:00-04:00: Potential 1m bottom for AAPL at 222.62 after 8 red candles, hammer, RSI 8.78, high vol vs. day  
2024-09-03 15:45:00-04:00: Potential 5m bottom for SPY at 550.79 after 5 red candles, hammer, RSI 9.16, high vol vs. day and prev 5
2024-09-06 10:00:00-04:00: Potential 1m bottom for NFLX at 670.96 after 3 red candles, hammer, RSI 0.84, high vol vs. day and prev 5
2024-09-03 14:27:00-04:00: Potential 1m top for TMO at 609.22 after 3 green candles, star, RSI 98.29, high vol vs. day and prev 5
2024-09-10 10:07:00-04:00: Potential 1m bottom for AXP at 243.95 after 4 red candles, hammer, RSI 10.29, high vol vs. day and prev 5
2024-08-30 15:56:00-04:00: Potential 1m top for RY at 120.95 after 5 green candles, star, RSI 86.98, high vol vs. day and prev 5
2024-09-04 13:32:00-04:00: Potential 1m bottom for CMCSA at 39.31 after 3 red candles, hammer, RSI 9.38, high vol vs. day and prev 5
2024-09-04 11:20:00-04:00: Potential 1m top for T at 20.83 after 5 green candles, star, RSI 100.00, high vol vs. day and prev 5
2024-09-04 13:35:00-04:00: Potential 5m bottom for T at 20.33 after 4 red candles, hammer, RSI 8.03, high vol vs. day and prev 5
etc.

And sorted by order of occurrence, you might end up with:

I’d suggest studying the 1-min or 5-min chart corresponding with your results to decide on a personal entry/exit strategy. Let’s take a look at some of my charts to better understand Aziz’s advice.

A failed reversal on CCI demonstrated by the red candle following our shooting star. No new low was made, and instead the uptrend continued until RSI reached around 90.

Some weak indecision candles may show up in your stock screen due to the way you set parameters. For this test, I had RSI = 85 to be the upper limit and required current volume to be greater than either that day’s average volume or just the previous 5 candle’s average volume. Stricter conditions here might have helped in our search for stronger entry signals.

Increasing wick_multiplier might also be a good way to avoid seeing shooting stars like this one in your results. By definition, the upper wick of a candle should be “significantly” taller than the candle’s body in order to be considered a shooting star, but in the above example it appears to be around the same height. Setting wick_multiplier = 2 would give you fewer, but potentially better, results in your screen.

Two more successful opening opportunities for day trades have been indicated in the following charts:

Hammer patterns successfully identified but Alibaba trend (left) fully reverses while AmEx downtrend (right) continues after a brief test.

Here we can again see the importance of relative volume in our decision-making. Whereas BABA fell drastically with high volume right before our hammer pattern was detected, AXP fell much more slowly and its marked indecision candle was not as strongly confirmed by volume. AXP’s volume at the marked candle may have been higher than the average of the 5 previous candles, but it wasn’t that much higher than the daily average, and in fact a new high in volume was reached a few candles later.

Notice here that RSI = 15 and RSI = 85 were effective in our detection of trend reversals, even if one of the reversals was very short-term. Aziz might have recommended we take both trades during the green candles following our detected hammers. Of course, we’d close the AXP trade almost immediately in response to a red candle on very high trading volume, but the BABA trade on the left could have comfortably lasted for an hour or 90 minutes before we decided to take a profit.

Clearly there’s still some ambiguity even with our rather strict trading criteria. Machine learning might help us handle this volatility, but until we have a model to automatically choose our entry/exit points, it’s highly recommended that you enter each trade with both a pre-determined a stop loss and profit target in mind.

Caveats & Next Steps

While the code provided will help you identify potential reversals for any asset, it’s essential to note that there is no reason why you should actually trade a reversal unless you can define it within your broader trading and investment goals. The insights from my code are by definition not actionable until you’ve decided on a full trading strategy—potentially one using the trendlines or S/R levels described above and in Aziz’s writing.

There are also further considerations to be made regarding volume within an asset’s broader liquidity. My code will consider all stock tickers during every trading session, but it’s best to trade only assets with high liquidity—for example, stocks with a daily trading volume of at least 500k shares and only those with a higher-than-average volume for the current trading day. I’ev also found it helpful to exclude any tickers for which yfinance provides incomplete data. Some asset prices don’t change every minute and therefore may not have enough liquidity for successful day trades. Avoiding these illiquid assets is a simple yet important step in trading.

Finally, it goes without saying that any strategy is incomplete without a calculation of the fees and taxes associated with day trading. Be sure to consider this in your backtest before making trades with real money.

Thank you for following along!

Leave a Reply

The maximum upload file size: 500 MB. You can upload: image, audio, video, document, spreadsheet, interactive, other. Links to YouTube, Facebook, Twitter and other services inserted in the comment text will be automatically embedded. Drop file here