Fix Look-Ahead Bias in Insider Trade Backtests
Your insider-trading backtest probably joins on the wrong date. Here's the SQL fix, the amendment edge case, and the unit test to catch it.

Your insider-trading backtest just told you a strategy returns 18% annually. There's a good chance that number is fiction, and the bug is hiding in a column you probably joined on without a second thought: transaction_date.
Form 4 filings carry two dates. One is transaction_date, the day the insider actually bought or sold. The other is filed_date, the day the SEC's EDGAR system disseminated the form to the public. A recent study of every Form 4 filed in July 2026, 11,241 filings in total, found the median gap between those two dates is 2 days. The 99th percentile gap is 91 days. The maximum observed gap was 3,140 days, a filing disseminated in 2026 for a transaction that happened in 2017. If your backtest treats transaction_date as the moment information became available, you are letting your model trade on data that did not exist yet.
Here's how to find that bug in your own pipeline and fix it for good.
Step 1: Confirm you have the wrong join
Open your backtest's data-loading code and search for wherever you filter or join insider trades against price data or rebalance dates. Nine times out of ten, it looks like this:
SELECT p.ticker, p.date, p.close, f.transaction_code
FROM prices p
JOIN form4_legs f
ON p.ticker = f.ticker
AND p.date = f.transaction_date
This join says "on the day of the trade, here's the price." That's fine for measuring the insider's own return. It is not fine for a strategy that reacts to the trade, because on transaction_date, the public did not know the trade happened. The signal wasn't live yet. Anywhere this pattern exists in your codebase, you have a leakage source.
Step 2: Rewrite the join around filed_date
The fix is mechanically simple but conceptually important: your backtest should only ever see a filing on or after its filed_date, never before.
SELECT p.ticker, p.date, p.close, f.transaction_code, f.transaction_date, f.filed_date
FROM prices p
JOIN form4_legs f
ON p.ticker = f.ticker
AND p.date >= f.filed_date
Notice the shift from equality to a point-in-time inequality. You're no longer asking "what happened on this date," you're asking "what information is available as of this date." That distinction is the entire fix. Every downstream signal, every feature column, every rebalance decision should be built from filed_date forward, never from transaction_date forward. For more on this, see read about how to backtest a polymarket trading bot for slippage.
Step 3: Build an as-of table instead of a flat join
A single join works for a toy example, but production backtests need an explicit as-of pattern so you can audit exactly what your model "knew" on any given day. Build a table that expands each filing into a validity window:
CREATE TABLE insider_signal_asof AS
SELECT
ticker,
filed_date AS valid_from,
transaction_code,
transaction_date,
shares,
price
FROM form4_legs
WHERE is_amendment = FALSE;
Then, when your backtest asks "what insider activity is known for ticker X as of date D," it queries WHERE valid_from <= D, never referencing transaction_date at all in the decision logic. Keep transaction_date around purely as a descriptive field for later analysis, like measuring how insiders traded relative to price moves. Never let it touch the part of the code that decides when a signal fires.
Step 4: Handle Form 4/A amendments explicitly
Amendments are where a lot of backtests quietly break even after you've fixed the main join. An original Form 4 gets filed, then weeks later a Form 4/A corrects a share count or adds a missed transaction. If you naively pull "latest data per transaction," you'll sometimes overwrite an original filing's filed_date with the amendment's filed_date, and other times you'll double count the position.
Treat amendments as a separate event with their own valid_from date, not a retroactive edit to the original:
SELECT
ticker,
filed_date AS valid_from,
is_amendment,
transaction_code,
shares
FROM form4_legs
ORDER BY ticker, valid_from;
Your as-of query should return the most recent record with valid_from <= D, which naturally means an amendment only affects the backtest from the day it was actually filed, not from the original transaction date and not from the original filing date either. This preserves the point-in-time truth: on day D, the market only knew what had been filed by day D, corrections included.
Also read: go deeper on redora vs nestjs-redis: which redis tool fits nestjs?
Step 5: Stress-test the long tail, not just the median
A 2-day median delay barely dents most monthly or weekly rebalance backtests. The tail is the part that quietly wrecks results. In the July 2026 sample, 190 filings landed 11 to 30 days after the transaction, 131 landed 31 to 90 days late, and 110 arrived more than 90 days after the trade. That means roughly 4% of filings, if joined on transaction_date, would leak information across at least one monthly rebalance boundary, sometimes several.
The leakage isn't evenly distributed across trade types either. Open-market sales filed on time in the high 90s percent of cases, likely because broker-assisted paperwork moves fast. Open-market purchases, the transaction type most insider-trading strategies actually care about, missed the 2-business-day deadline about 1 in 5 times. If your strategy's edge comes from insider buying signals specifically, your look-ahead bias is concentrated exactly where your alpha is supposed to live. Run your backtest twice, once filtered to purchases only, and check whether performance degrades meaningfully once you enforce the filed_date join. If it collapses, the original result was measuring information leakage, not skill.
Step 6: Unit-test point-in-time correctness directly
Don't rely on eyeballing results to catch this. Write a unit test that asserts, structurally, that no signal can reference data from the future relative to the simulated "today" of the backtest loop.
def test_no_future_leakage(asof_table, backtest_date):
visible = asof_table[asof_table["valid_from"] <= backtest_date]
assert (visible["transaction_date"] <= backtest_date).any() or len(visible) == 0
# the real assertion: nothing with valid_from > backtest_date leaked in
leaked = asof_table[
(asof_table["valid_from"] > backtest_date)
]
joined_in_test = leaked[leaked["ticker"].isin(visible["ticker"])]
assert joined_in_test.empty, "future filing leaked into backtest window"
Run this test across a handful of historical dates, including at least one date that falls inside a known amendment window and one that falls inside a long-tail delay case like the 2017-transaction-filed-in-2026 scenario. A backtest that passes this test on ordinary filings but fails on the extreme-delay edge case is telling you your as-of logic has a hardcoded assumption about "reasonable" delay windows. Fix that assumption; the tail is where the real damage happens.
Once these six steps are in place, expect two things. First, your backtest's raw returns will likely drop, especially for purchase-driven strategies, because you've removed the artificial edge that came from seeing filings early. Second, you'll have a system you can actually trust when you extend it, whether that means adding new transaction codes, backtesting a different sector, or wiring in a live data feed where point-in-time correctness isn't optional, it's the whole game.
Related Articles

How to Use Claude Code Subagents to Parallelize Development
Learn how to enhance your development workflow using Claude Code Subagents. This guide provides practical examples for parallelizing coding tasks.
Sep 13, 2025

Unlocking ChatGPT Developer Mode: Full MCP Client Access
Unlock the power of ChatGPT Developer Mode with full MCP client access. Discover how to enhance your coding projects and streamline development.
Sep 11, 2025

MCP Implementation at HubSpot: Elevating CRM with Context
Explore HubSpot's transformative MCP implementation for their CRM, detailing key strategies, challenges, and best practices for developers.
Sep 20, 2025