← Back to list

A Volume Based Strategy

QQQ | Daily Timeframe | 10 Year Backtest 147 Trades | 347.6% Return | 61.22% Win Rate

Bubble Analytics · 2026-05-25 22:23 · 0 claps · 6.1 min read
#algorithmic-trading #quantitative-finance #python-trading #backtesting #systematic-trading
Open on Medium ↗
Wiki topics: 💻 · Programming

A Volume Based Strategy

QQQ | Daily Timeframe | 10 Year Backtest

147 Trades | 347.6% Return | 61.22% Win Rate

This week’s article details our attempt to create a strategy using volume data onto which we have scaffolded the Williams%R indicator. The aim of the strategy is to use the Williams %R oscillator to pick out periods of bullish momentum to get long, versus periods of over exuberance to take profits and get short.

To start we have downloaded 10 years worth of daily OHLC bars for the Nasdaq 100 index (QQQ) from Yahoo Finance into a dataframe named df, and the backtest itself will be run using the backtesting.py library. As usual, we will show you how to calculate the indicators, apply them to price, generate trading signals, then run a full backtest.

The completed codebook along with our backtesting template can be downloaded via our GitHub profile, but for more detailed coding instruction you can refer to the video on our YouTube channel which shows you how to construct it from start to finish. Please note that the article strategy is for infotainment purposes only and should not be construed as financial advice.

Creating the Indicators/Overlays

Raw volume data typically gives you the number of shares traded per asset over a given period. In the codebook and video we consider 3 ways to adjust this metric, before we then overlay a standard price indicator onto it.

Volume / Close normalises share volume by price, which causes lower-priced stocks to produce larger values than higher-priced stocks with the same trading activity. This is a non-standard transformation that effectively biases the indicator toward cheaper assets rather than measuring true liquidity, and offsets what tends to be higher dollar amounts going into premium stocks.

Relative Volume compares current volume to its recent average, typically using a rolling mean. This helps identify unusually high or low trading activity relative to normal conditions, making it useful for detecting breakouts, strong momentum, or periods of reduced market interest.

Volume x Close calculates dollar volume, which gives us the total capital traded during the period. Unlike raw share volume, this accounts for price differences between assets and is commonly used as a more meaningful measure of liquidity and institutional participation. This is the volume calculation we will focus on today.

The code is as follows:

df['Vol'] = df['Volume'] * df['Close']

We then calculate the Williams %R based on this volume metric, calculating the highest high and lowest low over a 17-day lookback period, and using a formula of (HiHi — Close) / (HiHi — LoLo) * 100 to create an oscillator, before smoothing the output with a 3-period simple moving average.

def Williams(data, n):
    HiHi = df['Vol'].rolling(n).max()
    LoLo = df['Vol'].rolling(n).min()
    Close = df['Vol']
    will = ((HiHi - Close) / (HiHi - LoLo)) * 100
    will1 = will.rolling(3).mean()
    return will1

df['williams'] = Williams(df, 17)

The chart below shows you price above with the volume based Williams %R (Vol %R) indicator below:

Signal Generation

In the main, people generate buy signals for Williams %R values below 20, but since the indicator is oversold fairly infrequently, if we did that we would have only a handful of signals. Instead we focus on higher Vol %R readings to trigger long trades.

A buy signal is generated when yesterday’s Vol%R value is less than 50, and today’s Vol %R value is above 50. A Vol%R value moving from the lower bound into the upper bound asserts that the daily dollar amount being traded in the stock is increasing, which theoretically bodes well for a short term bullishness.

A sell signal is generated when yesterday’s Vol%R value is above 90, and today’s Vol%R is below 90, on the basis that a Vol%R value moving from the uppermost reaches of the oscillator before falling back below it would indicate that recent bullish exuberance has played out, and price is set to pullback in the short term.

For our signals, .iloc indexing is used to compare the current bar with prior bars to confirm that specific conditions have just occurred. Here, .iloc[i] represents the bar that has just closed, while .iloc[i-1] represents the bar immediately before it. So in the example data.Williams.iloc[i-1] < 50 and data.Williams.iloc[i] > 50, the Vol%R value was below 50 on the previous bar and has now closed above 50, confirming an upward cross.

If none of these buy or sell conditions are met, the signal is classed as zero and no action is taken. The signal function loops through each row of the dataframe looking for our trigger conditions and assigns a value of 1 (buy), 2 (sell), or 0 (do nothing) into a ‘signal’ column.

Our signal code looks like this:

def signal(data):
    signal = [0] * len(df)
    for i in range(2,len(df)):
        if (data.williams.iloc[i-1] < 50) and (data.williams.iloc[i] > 50):
            signal[i] = 1
        elif (data.williams.iloc[i-1] > 90) and (data.williams.iloc[i] < 90):
            signal[i] = 2
        else:
            signal[i] = 0
        df["signal"] = signal

signal(df)

Running the Backtest

Once our signal column values are populated, we can then run the backtest. We define a SIGNAL function to take in the values of our signal column, then create a strategy class called MyStrat.

Within MyStrat we initialise the SIGNAL function, create a price reference point we can key off to set stop losses and profit targets, and then use if statements to tell the backtesting program what action to take when a buy or sell signal is found.

In this backtest, where the signal equals 1 we buy with 99% of available capital (size=0.99), and apply a 9% profit target. If signal equals 2 we close any open long position and short with 99% of available capital, applying a 2% stop loss and a 2% profit target.

When we come to run the backtest, we tell it where to find our signal column values (df), which strategy class we want to run (MyStrat), define the starting capital allocation (cash=100_000), set it to use no leverage (margin=1), set a rule whereby we can only have one open position at a time (exclusive_orders=True), and tell it to factor in a 0.05% commission fee on the buy and sell (for a total fee of 0.1% per completed trade).

def SIGNAL():
    return df.signal

class MyStrat(Strategy):

    def init(self):
        super().init()
        self.signal = self.I(SIGNAL)

    def next(self):
        super().next()

        price = self.data.Close[-1]

        if self.signal == 1:
            if self.position.is_short or not self.position:
                self.position.close()
                self.buy(size=0.99, tp=1.09*price)

        elif self.signal == 2:
            if self.position.is_long or not self.position:
                self.position.close()
        self.sell(size=0.99, tp=0.98*price, sl=1.02*price)

bt = Backtest(df, MyStrat, cash=100_000, margin=1, exclusive_orders=True, commission=0.0005)
stats = bt.run()
stats

Strategy Results

Over the 10-year test period the strategy placed 147 trades, achieved a win rate of 61.22%, and returned 347.60% compared to 610.55% for buy-and-hold. Exposure time was 79.05%, meaning the strategy was invested for much of the time.

Maximum drawdown was quite high at -28.09%, but still lower than the peak-to-trough losses experienced by QQQ over the same period. The Sharpe ratio of 0.77 and Sortino ratio of 1.29 indicate reasonable risk-adjusted performance, with a profit factor of 2.25 and an average trade return of 1.04%. Stats are as follows:

In order to visualise the drawdown profile, a profit and loss graph is included below. Of note is the close to 2 year sideways-to-down period starting in 2018 that for sure would have caused consternation when experienced in real time:

We have enough trades to model risk, so for completeness a Monte Carlo simulation is also provided, and shows that if all the losing trades came at the start of the strategy, it would still have been underwater in year 5. Both the PnL chart and Montecarlo simulation suggest caution if using this strategy approach:

Overall the strategy appears reasonably effective at picking out periods where money is changing hands, but is not able to define the trend as accurately as other strategies, and this can lead to underperformance over time. While the strategy has made money after paying fees, some refinement would be necessary to reduce overall risk.

As always, further parameter optimisation, regime filtering, or volatility targeting may improve performance. This article strategy is presented for educational purposes only and should not be considered financial advice. For a detailed run through of the codebook, please watch the accompanying video.

[embed]


메타데이터
post_id
61069d760b8f
slug
a-volume-based-strategy-61069d760b8f
url
https://medium.com/@bubble_analytics/a-volume-based-strategy-61069d760b8f
canonical_url
https://medium.com/@bubble_analytics/a-volume-based-strategy-61069d760b8f
author_url
https://medium.com/@bubble_analytics
status
ok
fetched_at
2026-06-09 15:37:30