The 5% Lag: My Simple Code for Catching Undervalued Stocks
- Nikhil Adithyan
- 3d
- 8 min read
Ditch the P/E Ratio and follow this method

As investors, we always look for stocks to buy at lower prices. Typically, we consider fundamental data, such as the P/E ratio (Price-to-Earnings), to determine which stock is “cheaper.” However, this approach isn’t always the safest or the only method. Sometimes, a stock might have a lower P/E because the market expects less growth, which doesn’t necessarily mean it is truly cheaper.
But how can we implement this idea using only technical data? One approach is to compare a stock’s price to the index and buy the one that is underperforming. For example, if Microsoft’s price increases by 10% and Google’s by 5%, while the overall Technology sector rises about 10%, our strategy will “bet” that Microsoft is lagging and will soon catch up with the sector’s performance.
What should you expect from this article:
We are going to use the FMP suite of APIs to get the data needed for our backtest
We are going to identify the “laggers” and open long trades
We will present the outcome of those trades
We will examine single stocks and discuss the trades performed
Let’s code
As usual, we will perform the imports and set our parameters. Notice that we set the sector in our parameters. By using the same Python code yourself, you can run the same back test for nearly any sector.
import requests
import matplotlib.pyplot as plt
import pandas as pd
sector = 'Technology'
token = 'YOUR FMP TOKEN'
Next, we must establish our stock universe for the backtesting. We will use the FMP Stock Screener API to get Technology sector stocks, filtering to only those traded on NASDAQ.
url = f'https://financialmodelingprep.com/stable/company-screener'
querystring = {"apikey":token,"country":"US", "sector":sector, "isActiveTrading": True}
resp = requests.get(url, querystring).json()
df_sector_stocks = pd.DataFrame(resp)
df_sector_stocks = df_sector_stocks[df_sector_stocks['exchangeShortName'] == 'NASDAQ']
df_sector_stocks

Get the sector’s performance index using FMP’s Historical Market Sector Performance API from the beginning of this year. This API provides the daily average change in the specific sector, so we will need to convert this into an index.
from_date = "2025-01-01"
to_date = "2025-10-31"
url = f'https://financialmodelingprep.com/stable/historical-sector-performance'
querystring = {"apikey": token, "sector": sector, "from": from_date, "to": to_date}
resp = requests.get(url, querystring).json()
df_sector_performance = pd.DataFrame(resp)
df_sector_performance['date'] = pd.to_datetime(df_sector_performance['date'])
df_sector_performance = df_sector_performance.sort_values('date')
# create index starting at 100 using daily averageChange (%)
multipliers = 1 + (df_sector_performance['averageChange'] / 100.0)
index_series = 100 * multipliers.cumprod()
df_sector_performance['sectorIndex'] = index_series.values
df_sector_performance

The next step for our backtest is to gather the stock prices of our universe. We’ll obtain these using FMP Daily Chart API and compile them into a dataframe, with each stock represented as a column of adjusted closing prices.
symbols = df_sector_stocks['symbol'].dropna().unique().tolist()
df_list = []
for symbol in symbols:
url = f'https://financialmodelingprep.com/api/v3/historical-price-full/{symbol}'
querystring = {"apikey": token, "from": from_date, "to": to_date}
resp = requests.get(url, querystring).json()
if 'historical' not in resp or not resp['historical']:
continue
tmp = pd.DataFrame(resp['historical'])[['date', 'adjClose']].copy()
tmp['date'] = pd.to_datetime(tmp['date'])
tmp = tmp.sort_values('date')
tmp = tmp.rename(columns={'adjClose': symbol})
df_list.append(tmp)
if df_list:
df_closing_prices = df_list[0]
for t in df_list[1:]:
df_closing_prices = pd.merge(df_closing_prices, t, on='date', how='outer')
df_closing_prices = df_closing_prices.sort_values('date').reset_index(drop=True)
else:
df_closing_prices = pd.DataFrame(columns=['date'])
df_closing_prices

Additionally, we will add the sector’s performance to the same dataframe
df_closing_prices = pd.merge(
df_closing_prices,
df_sector_performance[['date', 'sectorIndex']],
on='date',
how='left'
)
Now the actual strategy implementation begins. First things first, we will calculate a 20-day moving average for each stock and create a new column named <stock name>_ma
df_ma = df_closing_prices.copy()
numeric_cols = df_ma.select_dtypes(include='number').columns
window = 20
ma_cols = {col: f"{col}_ma" for col in numeric_cols}
df_ma[list(ma_cols.values())] = df_ma[numeric_cols].rolling(window=window, min_periods=1).mean().values
df_ma

However, the moving average doesn’t mean a lot so that we will calculate the percentage difference of the actual price to the MA for each stock. Again, those columns will be the <stock price>_dist_pct
pct_cols = []
for col in [c for c in df_ma.columns if c not in ('date',)]:
ma_col = f"{col}_ma"
if ma_col in df_ma.columns:
pct_col = f"{col}_dist_pct"
df_ma[pct_col] = ((df_ma[col] - df_ma[ma_col]) / df_ma[ma_col]) * 100
pct_cols.append(pct_col)
ma_suffix_cols = [c for c in df_ma.columns if c.endswith('_ma')]
if ma_suffix_cols:
df_ma = df_ma.drop(columns=ma_suffix_cols)
df_ma

Now set up a function that will backtest a trade:
We will be passing the stock symbol and the date on which we will enter
We will calculate the exit date. The condition to exit is that the moving average of the stock “caught up” with the moving average of the sector.
The function will return a dictionary with the details of the trade, including the return
def backtest_trade(df, symbol, date):
price = df.loc[df['date'] == date][symbol].values[0]
df_tmp = df.copy()
df_tmp['date'] = pd.to_datetime(df_tmp['date'])
start_date = pd.to_datetime(date)
sym_col = f"{symbol}_dist_pct"
if sym_col in df_tmp.columns and 'sectorIndex_dist_pct' in df_tmp.columns:
mask_after = df_tmp['date'] > start_date
cond = df_tmp[sym_col] > df_tmp['sectorIndex_dist_pct']
next_rows = df_tmp.loc[mask_after & cond].sort_values('date')
if not next_rows.empty:
first_row = next_rows.iloc[0]
else:
return None
else:
return None
return {"symbol":symbol, "entry_date":date, "entry_price":price, "exit_date":first_row['date'].strftime('%Y-%m-%d'), "exit_price":first_row[symbol], "return_pct":((first_row[symbol]-price)/price)*100}
Now we have all the data and the functionality needed to run our back test:
We define a threshold of 5%. When the stock is lagging more than this threshold, we will enter a long trade
We will loop through the dataframe
Identify, for each date, which stocks meet the entry criteria based on the threshold described above and run the function that will return the results of the trade.
Ultimately, we will have a dataframe containing all the trades that have been backtested.
threshold = 5.0 # percentage points
results = []
sector_ma = df_ma['sectorIndex'].rolling(window=window, min_periods=1).mean()
df_ma['sectorIndex_dist_pct'] = ((df_ma['sectorIndex'] - sector_ma) / sector_ma) * 100
price_cols = [c for c in df_ma.columns if c not in ('date', 'sectorIndex') and not c.endswith('_dist_pct')]
for _, row in df_ma.iterrows():
date = row['date']
for sym in price_cols:
sym_dist_col = f"{sym}_dist_pct"
if sym_dist_col in df_ma.columns and pd.notna(row.get(sym_dist_col)) and pd.notna(
row.get('sectorIndex_dist_pct')):
if (row['sectorIndex_dist_pct'] - row[sym_dist_col]) >= threshold:
res = backtest_trade(df_ma, sym, date)
if isinstance(res, dict):
results.append(res)
df_backtests = pd.DataFrame(results)
df_backtests
To avoid complicating the code further, I intentionally included a mistake in the code that we will fix with the next piece of code.
The script above is quite simple — for some situations, simplicity can be better. It opens trades for a stock without verifying if there’s already an open position for that stock. For instance, if Apple stock meets the entry criteria and a trade is opened today, and the next day the stock remains a “lagger,” a new trade will still be initiated. The code below makes sure that only the first trade is kept, removing all subsequent trades until an exit occurs.
df_bt = df_backtests.copy()
df_bt['entry_date'] = pd.to_datetime(df_bt['entry_date'])
df_bt['exit_date'] = pd.to_datetime(df_bt['exit_date'])
def drop_overlaps(group: pd.DataFrame) -> pd.DataFrame:
g = group.sort_values('entry_date').reset_index(drop=True)
kept = []
last_exit = pd.Timestamp.min
for _, row in g.iterrows():
if row['entry_date'] >= last_exit:
kept.append(row)
last_exit = row['exit_date']
# else: overlaps with a kept trade that started earlier -> skip
return pd.DataFrame(kept)
# Apply per symbol to avoid cross-symbol interference
df_backtests = (
df_bt.groupby('symbol', group_keys=False)
.apply(drop_overlaps)
.reset_index(drop=True)
)
df_backtests

As shown above, we have nearly 2000 trades, providing enough meaningful data. They are all stored in the df_backtests, and we are going to use this one to analyse the results
Results Analysis
Now let’s analyse some results. First, some very basics
total_lines = len(df_backtests)
avg_return = df_backtests['return_pct'].mean()
median_return = df_backtests['return_pct'].median()
print(f"Total lines: {total_lines}")
print(f"Average return_pct: {avg_return:.4f}%")
print(f"Median return_pct: {median_return:.4f}%")

We have around 1900 trades, with an average return of 3.92%, while the median is slightly higher at 4.26%. Initial results are promising; however, let’s dive into some more details.
df_backtests['entry_date'] = pd.to_datetime(df_backtests['entry_date'])
df_backtests['exit_date'] = pd.to_datetime(df_backtests['exit_date'])
# New column: number of days trade was open
df_backtests['days_open'] = (df_backtests['exit_date'] - df_backtests['entry_date']).dt.days
# Aggregate: average return per holding period (filter non-negative days)
agg = (
df_backtests[df_backtests['days_open'] >= 0]
.groupby('days_open', as_index=False)['return_pct']
.mean()
.rename(columns={'return_pct': 'avg_return_pct'})
)
# Plot
plt.figure(figsize=(10, 5))
plt.bar(agg['days_open'], agg['avg_return_pct'], color='tab:blue', alpha=0.8)
plt.title('Average Return by Holding Period (Days Open)')
plt.xlabel('Days Open')
plt.ylabel('Average Return (%)')
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()

This is a very promising outcome. It appears our strategy is effective for trades open for up to 20 days. However, if the trade does not meet the exit criteria within those initial 20 days, the results tend to be significantly worse.
Let’s see some details on that:
df_backtests_20 = df_backtests.copy()
df_backtests_20['entry_date'] = pd.to_datetime(df_backtests_20['entry_date'])
df_backtests_20['exit_date'] = pd.to_datetime(df_backtests_20['exit_date'])
df_backtests_20['days_open'] = (df_backtests_20['exit_date'] - df_backtests_20['entry_date']).dt.days
df_backtests_20 = df_backtests_20[df_backtests_20['days_open'] <= 20]
total_trades_20 = len(df_backtests_20)
avg_return_20 = df_backtests_20['return_pct'].mean()
print(f"Total trades (<=20 days): {total_trades_20}")
print(f"Average return_pct (<=20 days): {avg_return_20:.4f}%")

What is promising about that is that over 50% of the initial trades were exited within the first 20 days, yielding an impressive average return of 12.84% in just those 20 days. Projected annually, this exceeds 700%!
Analyze per stock
One more interesting aspect would be to see the results per stock.
First, let’s calculate the average return per stock and get the top ones.
avg_per_symbol = (
df_backtests
.groupby('symbol', as_index=False)
.agg(avg_return_pct=('return_pct', 'mean'),
trades=('return_pct', 'size'))
.sort_values(['avg_return_pct', 'trades'], ascending=[False, False])
.reset_index(drop=True)
)
avg_per_symbol.head(5)

Some stocks returned more than 100% with this strategy. Let’s plot DeFi Development Corp. (DFDV)
symbol = "DFDV"
row_txt = avg_per_symbol[avg_per_symbol['symbol'] == symbol][['symbol', 'avg_return_pct', 'trades']]
df_plot = df_closing_prices[['date', symbol, 'sectorIndex']].dropna().copy()
df_plot['date'] = pd.to_datetime(df_plot['date'])
# Normalize both series to start at 100 for comparability
base_sym = df_plot[symbol].iloc[0]
base_idx = df_plot['sectorIndex'].iloc[0]
df_plot[f'{symbol}_norm'] = (df_plot[symbol] / base_sym) * 100.0
df_plot['sectorIndex_norm'] = (df_plot['sectorIndex'] / base_idx) * 100.0
# Compute moving averages (using existing 'window' if available, else default to 20)
ma_window = window if 'window' in globals() else 20
df_plot[f'{symbol}_norm_ma'] = df_plot[f'{symbol}_norm'].rolling(window=ma_window, min_periods=1).mean()
df_plot['sectorIndex_norm_ma'] = df_plot['sectorIndex_norm'].rolling(window=ma_window, min_periods=1).mean()
plt.figure(figsize=(12, 6))
plt.plot(df_plot['date'], df_plot[f'{symbol}_norm'], label=f"{symbol} (Base=100)", color='tab:blue', linewidth=1.5)
plt.plot(df_plot['date'], df_plot['sectorIndex_norm'], label="Sector Index (Base=100)", color='tab:orange',
linewidth=1.5)
df_pltr_trades = df_backtests[df_backtests['symbol'] == symbol].copy()
if not df_pltr_trades.empty:
df_pltr_trades['entry_date'] = pd.to_datetime(df_pltr_trades['entry_date'])
df_pltr_trades['exit_date'] = pd.to_datetime(df_pltr_trades['exit_date'])
for _, tr in df_pltr_trades.iterrows():
mask = (df_plot['date'] >= tr['entry_date']) & (df_plot['date'] <= tr['exit_date'])
# Draw the trade period on the normalized price
color = 'tab:green' if tr['return_pct'] > 0 else 'tab:red'
plt.plot(df_plot.loc[mask, 'date'], df_plot.loc[mask, f'{symbol}_norm'],
color=color, linewidth=3, alpha=0.7)
# Add avg_per_symbol info as text on the plot (top-right corner)
if not row_txt.empty:
r = row_txt.iloc[0]
txt = f"Avg Return: {r['avg_return_pct']:.2f}% | Trades: {int(r['trades'])}"
plt.gcf().text(0.98, 0.98, txt, fontsize=11, ha='right', va='top',
bbox=dict(facecolor='white', alpha=0.7, edgecolor='gray'))
plt.title(f"{symbol} vs Sector Index (Normalized to 100)")
plt.xlabel("Date")
plt.ylabel("Normalized Level")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()

The green part of the stock’s line shows the winning trades, while the red part indicates the losing trades. The reason the strategy was so successful is because it caught the price explosion around April 2025. This is good in terms of return, but bad because it seems a bit random.
By reviewing some additional stocks, we realise it is better to test our premise on more “known” stocks to gain further insights into the strategy. So, let’s check NVIDIA.

From analysing the plot, we see that all trades were profitable. This is logical, and we will explain why. The strategy enters when a stock underperforms within its sector. Nvidia, a dominant stock in the sector, might seem obvious — if it underperforms, it will eventually catch up one way or another.
While Nvidia performed well overall during our backtesting period, Apple did not fare as well, so let’s plot AAPL.

Once again, AAPL only experienced winning trades. The strategy refrained from trading during Apple’s drawdowns, or when it did trade, it caught up and eventually turned a profit (April and trade wars!).
Conclusions and ideas
You can try the idea using the same Python code with different sectors, windows, or thresholds, or update the code with a different exit strategy (like the 20 days we discussed earlier). However, based on the results we’ve seen so far, we can confidently say:
This is not a strategy that you will adopt as your long-term or primary approach. It provides opportunities but also considerable risks.
It looks like it performs well as an entry signal. It might be better to monitor it as an entry signal, while the exit can be more based on a fundamental approach.
As a strategy, it looks great as long as you are in the trade for a relatively short amount of time. Apparently, this makes absolute sense since after that period, a lot of things might happen for the stock or for the sector, and our exit strategy was very simplistic.
Dominant sector stocks appear safer. When evaluating a stock investment, it’s helpful to see if related stocks are underperforming the sector and to consider them as alternatives.
Finally, we can definitely say that, as information, it looks significant, and even if your strategy is 100% fundamental, it would provide you with priceless information to know how a stock moves compared to the sector and its peers.
Thank you for reading, and hope you enjoyed it.