In January 2024 I wrote about the insanity of the magnificent seven dominating the MSCI World Index, and I wondered how long the number can continue to go up? It has continued to surge upward at an accelerating pace, which makes me worry that a crash is likely closer. As a software professional I decided to analyze if using stop-loss orders could be a reliable way to automate avoiding deep drawdowns.
As everyone with some savings in the stock market (hopefully) knows, the stock market eventually experiences crashes. It is just a matter of when and how deep the crash will be. Staying on the sidelines for years is not a good investment strategy, as inflation will erode the value of your savings. Assuming the current true inflation rate is around 7%, the price of a restaurant dinner that is today 20 euros will cost 24.50 euros in three years. Savings of 1000 euros today would drop in purchasing power from 50 dinners to only 40 dinners in three years.
Hence, if you intend to retain the value of your dear savings, they need to be invested in something that grows in value. Most people try to beat inflation by buying shares in stable companies, directly or via broad market ETFs. These historically grow faster than inflation during normal years, but likely drop in value during recessions.
What is a trailing stop-loss order?
What if you could buy stocks to benefit from their value increasing without having to worry about a potential crash? All modern online stock brokers have a feature called stop-loss, where you can enter a price at which your stocks automatically get sold if they drop down to that price. A trailing stop-loss order is similar, but instead of a fixed price, you enter a margin (e.g. 10%). If the stock price rises, the stop-loss price will trail upwards by that margin.
For example, if you buy a share at 100 euros and it has risen to 110 euros, you can set a 10% trailing stop-loss order which automatically sells it if the price drops 10% from the peak of 110 euros, at 99 euros. Thus no matter what happens, you lost only 1 euro. And if the stock price continues to rise to 150 euros, the trailing stop-loss would automatically readjust to 150 euros minus 10%, which is 135 euros (150-15=135). If the price dropped to 135 euros, you would lock in a gain of 35 euros, which is not the peak price of 150 euros, but still better than whatever the price fell down to as a result of a large crash.
In the simple case above it obviously makes sense in theory, but it might not make sense in practice. Prices constantly oscillate, so you don’t want a margin that is too small, otherwise you exit too early. Conversely, having a large margin may result in a too large of a drawdown before exiting. If markets crash rapidly it might be that nobody buys your stocks at the stop-loss price and shares have to be sold at an even lower price. Also, what will you do once the position is sold? The reason you invested in the stock market was to avoid holding cash, so would you buy the same stock back when the crash bottoms? But how will you know when the bottom has been reached?
Backtesting stock market strategies with Python, YFinance, Pandas and Lightweight Charts
I am not a professional investor, and nobody should take investment advice from me. However, I know what backtesting is and how to leverage open source software. So, I wrote a Python script to test if the trading strategy of using trailing stop-loss orders with specific margin values would have worked for a particular stock.
First you need to have data. YFinance is a handy Python library that can be used to download the historic price data for any stock ticker on Yahoo.com. Then you need to manipulate the data. Pandas is the Python data analysis library with advanced data structures for working with relational or labeled data. Finally, to visualize the results, I used Lightweight Charts, which is a fast, interactive library for rendering financial charts, allowing you to plot the stock price, the trailing stop-loss line, and the points where trades would have occurred. I really like how the zoom is implemented in Lightweight Charts, which makes drilling into the datapoints feel effortless.
The full solution is not polished enough to be published for others to use, but you can piece together your own by reusing some of the key snippets. To avoid re-downloading the same data repeatedly, I implemented a small caching wrapper that saves the data locally (as Parquet files):
CACHE_DIR.mkdir(parents=True, exist_ok=True)
end_date = datetime.today().strftime("%Y-%m-%d")
cache_file = CACHE_DIR / f"{TICKER}-{START_DATE}--{end_date}.parquet"
if cache_file.is_file():
dataframe = pandas.read_parquet(cache_file)
print(f"Loaded price data from cache: {cache_file}")
else:
dataframe = yfinance.download(
TICKER,
start=START_DATE,
end=end_date,
progress=False,
auto_adjust=False
)
dataframe.to_parquet(cache_file)
print(f"Fetched new price data from Yahoo Finance and cached to: {cache_file}")The dataframe is a Pandas object with a powerful API. For example, to print a snippet from the beginning and the end of the dataframe to see what the data looks like you can use:
print("First 5 rows of the raw data:")
print(df.head())
print("Last 5 rows of the raw data:")
print(df.tail())Example output:
First 5 rows of the raw data
Price Adj Close Close High Low Open Volume
Ticker BNP.PA BNP.PA BNP.PA BNP.PA BNP.PA BNP.P
Dat
2014-01-02 29.956285 55.540001 56.910000 55.349998 56.700001 316552
2014-01-03 30.031801 55.680000 55.990002 55.290001 55.580002 210044
2014-01-06 30.080338 55.770000 56.230000 55.529999 55.560001 185142
2014-01-07 30.943321 57.369999 57.619999 55.790001 55.880001 370397
2014-01-08 31.385597 58.189999 59.209999 57.750000 57.790001 489940
Last 5 rows of the raw data
Price Adj Close Close High Low Open Volume
Ticker BNP.PA BNP.PA BNP.PA BNP.PA BNP.PA BNP.P
Dat
2025-12-11 78.669998 78.669998 78.919998 76.900002 76.919998 357918
2025-12-12 78.089996 78.089996 80.269997 78.089996 79.470001 280477
2025-12-15 79.080002 79.080002 79.449997 78.559998 78.559998 233852
2025-12-16 78.860001 78.860001 79.980003 78.809998 79.430000 283057
2025-12-17 80.080002 80.080002 80.150002 79.080002 79.199997 262818Adding new columns to the dataframe is easy. For example I used a custom function to calculate relative strength index (RSI) and to add a new column “RSI” with a value for every row based on the price from that row, only one line of code is needed, without custom loops:
df["RSI"] = compute_rsi(df["price"], period=14)After manipulating the data, the series can be converted into an array structure and printed as JSON into a placeholder in an HTML template:
baseline_series = [
{"time": ts, "value": val}
for ts, val in df_plot[["timestamp", BASELINE_LABEL]].itertuples(index=False)
]
baseline_json = json.dumps(baseline_series)
template = jinja2.Template("template.html")
rendered_html = template.render(
title=title,
heading=heading,
description=description_html,
...
baseline_json=baseline_json,
...
)
with open("report.html", "w", encoding="utf-8") as f:
f.write(rendered_html)
print("Report generated!")In the HTML template the marker {{ variable }} in Jinja syntax gets replaced with the actual JSON:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }}</title>
...
</head>
<body>
<h1>{{ heading }}</h1>
<div id="chart"></div>
<script>
// Ensure the DOM is ready before we initialise the chart
document.addEventListener('DOMContentLoaded', () => {
// Parse the JSON data passed from Python
const baselineData = {{ baseline_json | safe }}
const strategyData = {{ strategy_json | safe }}
const markersData = {{ markers_json | safe }}
// Create the chart – use a unique variable name to avoid any clash with the DOM element ID
const chart = LightweightCharts.createChart(document.getElementById('chart'),
width: document.getElementById('chart').clientWidth
height: 500
layout:
background: { color: "#222" }
textColor: "#ccc"
}
grid:
vertLines: { color: "#555" }
horzLines: { color: "#555" }
}
})
// Add baseline serie
const baselineSeries = chart.addLineSeries(
title: '{{ baseline_label }}'
lastValueVisible: false
priceLineVisible: false
priceLineWidth: 1
})
baselineSeries.setData(baselineData)
baselineSeries.priceScale().applyOptions(
entireTextOnly: true
})
// Add strategy serie
const strategySeries = chart.addLineSeries(
title: '{{ strategy_label }}'
lastValueVisible: false
priceLineVisible: false
color: '#FF6D00'
)
strategySeries.setData(strategyData)
// Add buy/sell markers to the strategy serie
strategySeries.setMarkers(markersData)
// Fit the chart to show the full data range (full zoom
chart.timeScale().fitContent()
})
</script>
</body>
</html>There are also Python libraries built specifically for backtesting investment strategies, such as Backtrader and Zipline, but they do not seem to be actively maintained, and probably have too many features and complexity compared to what I needed for doing this simple test.
The screenshot below shows an example of backtesting a strategy on the Waste Management Inc stock from January 2015 to December 2025. The baseline “Buy and hold” scenario is shown as the blue line and it fully tracks the stock price, while the orange line shows how the strategy would have performed, with markers for the sells and buys along the way.

Results
I experimented with multiple strategies and tested them with various parameters, but I don’t think I found a strategy that was consistently and clearly better than just buy-and-hold.
It basically boils down to the fact that I was not able to find any way to calculate when the crash has bottomed based on historical data. You can only know in hindsight that the price has stopped dropping and is on a steady path to recovery, but at that point it is already too late to buy in. In my testing, most strategies underperformed buy-and-hold because they sold when the crash started, but bought back after it recovered at a slightly higher price.
In particular when using narrow margins and selling on a 3-6% drawdown the strategy performed very badly, as those small dips tend to recover in a few days. Essentially, the strategy was repeating the pattern of selling 100 stocks at a 6% discount, then being able to buy back only 94 shares the next day, then again selling 94 shares at a 6% discount, and only being able to buy back maybe 90 shares after recovery, and so forth, never catching up to the buy-and-hold.
The strategy worked better in large market crashes as they tended to last longer, and there were higher chances of buying back the shares while the price was still low. For example in the 2020 crash selling at a 20% drawdown was a good strategy, as the stock I tested dropped nearly 50% and remained low for several weeks, so the strategy bought back the stocks while the price was still low and had not yet started to climb significantly. But that was just a lucky incident, as the delta between the trailing stop-loss margin of 20% and total crash of 50% was large enough. If the crash would have been only 25%, the strategy would have missed the rebound and ended up buying back the stocks at a slightly higher price.
Also, note that the simulation assumes that the trade itself is too small to affect the price formation. We should keep in mind that in reality, if a lot of people have stop-loss orders in place, a large price drop would trigger all of them, and create a flood of sales orders, which in turn would affect the price and drive it lower even faster and deeper. Luckily, it seems that stop-loss orders are generally not a good strategy, and we don’t need to fear that too many people would be using them.
Conclusion
Even though using a trailing stop-loss strategy does not seem to help in getting consistently higher returns based on my backtesting, I would still say it is useful in protecting from the downside of stock investing. It can act as a kind of “insurance policy” to considerably decrease the chances of losing big while increasing the chances of losing a little bit. If you are risk-averse, which I think I probably am, this tradeoff can make sense. I’d rather miss out on an initial 50% loss and an overall 3% gain on recovery than have to sit through weeks or months with a 50% loss before the price recovers to prior levels.
Most notably, the trailing stop-loss strategy works best if used only once. If it is repeated multiple times, the small losses in gains will compound into big losses overall.
Thus, I think I might actually put this automation in place at least on the stocks in my portfolio that have had the highest gains. If they keep going up, I will ride along, but once the crash happens, I will be out of those particular stocks permanently.
Do you have a favorite open source investment tool or are you aware of any strategy that actually works? Comment below!
