Insights
Backtesting a Short-Selling Strategy in Python
Alphanume Team · June 10, 2026
From an event feed to borrow-cost-adjusted short returns.
Running a backtest short selling python strategy is deceptively easy to do wrong. A naïve implementation picks events in hindsight, ignores borrow costs, and quietly drops every ticker that later got delisted — all of which inflate paper returns until they bear no resemblance to what you would have earned in practice. This tutorial works through the mechanics honestly: sourcing dilutive events from the API, entering positions with a realistic lag, computing forward returns, deducting annualized borrow cost pro-rated over the holding period, and building an equity curve that reflects the full universe including names that eventually stopped trading. For the strategic context behind these design choices, see how to backtest a short-selling strategy before diving into the code.
Setup and the shared request helper
You need requests, pandas, and numpy. Every Alphanume endpoint shares one calling pattern — a GET against the base URL with api_key as a query parameter and the payload returned under a data key — so a single helper keeps the rest of the code clean.
import os
import requests
import pandas as pd
import numpy as np
BASE_URL = "https://api.alphanume.com/v1"
API_KEY = os.environ["ALPHANUME_API_KEY"]
def get_data(endpoint, **params):
params["api_key"] = API_KEY
resp = requests.get(f"{BASE_URL}/{endpoint}", params=params, timeout=30)
resp.raise_for_status()
return resp.json()["data"]
The raise_for_status() call converts a silent 401 or 429 into an immediate exception. Without it, the error body parses silently into a DataFrame of nonsense and the failure mode is invisible until you look at the returns.
Pulling dilution events
The entry signal here is a dilutive offering — a secondary equity offering, an at-the-market program, or a convertible that pressures the float. The Stock Dilution dataset exposes these events with the date the offering was announced or priced, which is the correct timestamp to use in a point-in-time backtest. Pulling by date range and loading into a DataFrame takes four lines.
def fetch_dilution_events(start_date, end_date):
rows = get_data(
"dilution",
start_date=start_date,
end_date=end_date,
)
df = pd.DataFrame(rows)
df["event_date"] = pd.to_datetime(df["event_date"])
return df.sort_values("event_date").reset_index(drop=True)
events = fetch_dilution_events("2022-01-01", "2024-12-31")
print(events[["ticker", "event_date", "offering_type", "shares_offered"]].head())
Keep every row, including tickers you have never heard of. Filtering to survivors before the backtest even starts is how survivorship bias sneaks in — a stock that diluted repeatedly and later got delisted still represents real P&L that would have hit your book.
Entry timing and the look-ahead trap
The single most common source of inflated short-selling backtests is entering at the event price rather than at a price you could actually have traded. If the offering is announced after-hours, the first tradable price is the next morning's open. Entering at the prior close or at the intraday print that triggered your screen is look-ahead in disguise.
The function below attaches the entry date to each event, applying a configurable lag in trading days. With lag_days=1 you assume you enter at the open on the day after the event — conservative and realistic for a liquid name, though you may want lag_days=2 if your price feed gives you opens rather than prior-close figures.
def attach_entry_dates(events_df, price_dates, lag_days=1):
"""
For each event, find the first available price date
that is at least lag_days after event_date.
price_dates: a sorted DatetimeIndex of available trading days.
"""
records = []
for _, row in events_df.iterrows():
earliest = row["event_date"] + pd.offsets.BusinessDay(lag_days)
future = price_dates[price_dates >= earliest]
if future.empty:
continue
records.append({
**row.to_dict(),
"entry_date": future[0],
})
return pd.DataFrame(records)
Using pd.offsets.BusinessDay rather than a raw timedelta skips weekends automatically, so an event on Friday maps to a Tuesday entry rather than a Sunday one.
Computing forward returns and deducting borrow cost
A short position profits when the price falls. The gross holding-period return for a short is the negative of the price change — if the stock drops 15 % you earn 15 % on the notional. But you also pay a daily borrow fee to your prime broker for the right to be short. Hard-to-borrow names can run 20–50 % annualized; easy-to-borrow large caps often sit below 1 %. The function below accepts a DataFrame of prices indexed by date, a list of entry records, a holding period in trading days, and an annualized borrow rate, and returns one row of P&L per trade.
TRADING_DAYS_PER_YEAR = 252
def compute_trade_returns(
entries_df,
prices: pd.Series,
holding_days=10,
annual_borrow_rate=0.05,
):
"""
entries_df: DataFrame with columns [ticker, entry_date].
prices: Series indexed by date, for a single ticker.
Returns a DataFrame with gross_return, borrow_cost, net_return columns.
"""
daily_borrow = annual_borrow_rate / TRADING_DAYS_PER_YEAR
price_dates = prices.index.sort_values()
results = []
for _, row in entries_df.iterrows():
entry_date = row["entry_date"]
if entry_date not in prices.index:
continue
entry_price = prices.loc[entry_date]
future = price_dates[price_dates > entry_date]
if len(future) < holding_days:
continue
exit_date = future[holding_days - 1]
exit_price = prices.loc[exit_date]
gross_return = (entry_price - exit_price) / entry_price
borrow_cost = daily_borrow * holding_days
net_return = gross_return - borrow_cost
results.append({
"ticker": row["ticker"],
"entry_date": entry_date,
"exit_date": exit_date,
"gross_return": gross_return,
"borrow_cost": borrow_cost,
"net_return": net_return,
})
return pd.DataFrame(results)
The borrow cost line is daily_borrow * holding_days — straightforward pro-rating of the annualized rate over the actual number of days the position was open. This is not a subtlety; on a 10-day hold at 40 % annual borrow, you are already giving up 1.6 percentage points before the price even moves.
Building the equity curve
Once you have a row of net returns per trade, assembling an equity curve is a matter of deciding how to weight and sequence the trades. A simple equal-weight curve allocates 1 unit of notional to every trade regardless of conviction, and compounds the returns in event-date order. This is the right starting point because it shows raw strategy edge — not portfolio construction skill.
def equity_curve(trades_df, initial_capital=1.0):
if trades_df.empty:
return pd.Series(dtype=float)
ordered = trades_df.sort_values("entry_date").reset_index(drop=True)
curve = pd.Series(
(1 + ordered["net_return"]).cumprod() * initial_capital,
index=ordered["entry_date"],
name="equity",
)
return curve
curve = equity_curve(all_trades)
total_return = curve.iloc[-1] - 1
print(f"Trades: {len(all_trades)} | Final equity: {curve.iloc[-1]:.4f}")
Do not interpret a rising curve here as a green light. The equal-weight assumption means a single meme-stock blowup that costs 80 % on one trade gets the same weight as a clean 10 % winner. Step two is always to look at the distribution of net_return, not just the terminal value.
The correctness checklist
Before drawing any conclusions from a short-selling backtest, run through these four questions. Missing any one of them produces a number that cannot be trusted.
Point-in-time universe. The set of names eligible on a given date must reflect only information available on that date. A company that had not yet gone public in 2022 cannot appear in a 2022 screen, even if you are pulling data today.
Survivorship bias. Include every ticker that had a dilution event in the window, whether it is still trading or not. A stock that diluted twice and then got acquired or delisted still belongs in the sample. Avoiding survivorship bias is a recurring theme across all event-driven strategies, not just options — the same principle applies here.
No look-ahead in entry timing. Entry must occur on a date strictly after the event timestamp. Even a one-day error compresses spread and eliminates the gap that real-world execution would eat.
Borrow cost netting. Every trade that is profitable gross may be a loser net. This is especially true for the highest-conviction signals — the stocks that screen best after a dilution event are often the hardest to borrow. If you do not have name-level borrow rate data, a flat 5–10 % assumption is better than zero, but treat any strategy that lives or dies on that assumption with skepticism.
Putting it together
The full pipeline chains the four functions above: fetch events, attach entry dates, iterate over tickers to compute trade returns against a price series, and plot the equity curve. Each step is a pure function that takes a DataFrame and returns a DataFrame, so you can swap in a different event source, a different price feed, or a different borrow rate table without touching the surrounding logic. That modularity also makes it straightforward to run the same backtest on a delisted-inclusive universe versus a survivors-only universe — a comparison that reliably shows how much of the apparent edge evaporates once dead names are included.
The mechanics shown here are deliberately minimal. A production implementation would add position sizing, gross exposure limits, sector neutrality, and a transaction-cost model that accounts for the bid-ask spread on hard-to-borrow names. But the skeleton — event feed, lagged entry, forward return, borrow deduction, equity curve — is the load-bearing structure, and getting it right before adding complexity is the only way to know whether the edge you are measuring is real.