forward_returns
Answered against 22 years of US equities and 12 years of US options data and published with the query that produced it. This result is stored as of 2026-09-20, from investing-at-all-time-highs.
| horizon | record_closes | other_closes | record_median_pct | other_median_pct | record_positive_pct | other_positive_pct |
|---|---|---|---|---|---|---|
| 1 year | 427 | 4108 | 12.6 | 13.7 | 78.2 | 82.6 |
| 3 years | 343 | 3688 | 26.8 | 38.5 | 99.4 | 93.6 |
| 5 years | 326 | 3201 | 66.3 | 75 | 99.4 | 97.6 |
- Rows × columns
- 3 × 7
- Computed
- Completeness
- No missing values
- Source
- US exchange, SIP and OPRA market data
- Licence
- Strasmore terms · free, no signup
What each column holds
| Column | Type | Range | Notes |
|---|---|---|---|
horizon |
text | 3 distinct values (1 year, 3 years, 5 years) | |
record_closes |
number | 326 to 427 | |
other_closes |
number | 3,201 to 4,108 | |
record_median_pct |
number | 12.6 to 66.3 | percent |
other_median_pct |
number | 13.7 to 75 | percent |
record_positive_pct |
number | 78.2 to 99.4 | percent |
other_positive_pct |
number | 82.6 to 97.6 | percent |
Computed from Strasmore's warehouse of US exchange, SIP and OPRA market data. Equity prices are delayed; options greeks and implied volatility are end-of-day. This result is stored, not recomputed on load — it is exactly the numbers that were returned on , and the query below is what returned them.
Run it yourself
This is the exact query behind the result above. Change a ticker, a date or a column and run it against the warehouse — no account, no key. The no-signup tier is smaller than the one this page was computed on; a query that reaches past it comes back saying which plan runs it.
WITH
daily AS
(
SELECT
date,
toFloat64(argMax(close, _ingest_time)) AS close
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
GROUP BY date
),
flagged AS
(
SELECT
date,
close,
close >= max(close) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS is_record,
min(date) OVER () AS series_start,
leadInFrame(close, 252) OVER (ORDER BY date ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS close_1y,
leadInFrame(close, 756) OVER (ORDER BY date ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS close_3y,
leadInFrame(close, 1260) OVER (ORDER BY date ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS close_5y
FROM daily
),
unpivoted AS
(
SELECT
date,
close,
is_record,
arrayJoin([('1 year', close_1y), ('3 years', close_3y), ('5 years', close_5y)]) AS pair,
tupleElement(pair, 1) AS horizon,
tupleElement(pair, 2) AS close_fwd
FROM flagged
WHERE date >= addYears(series_start, 4)
)
SELECT
horizon,
countIf(is_record = 1) AS record_closes,
countIf(is_record = 0) AS other_closes,
round(quantileDeterministicIf(0.5)(100 * (close_fwd / close - 1), toYYYYMMDD(date), is_record = 1), 1) AS record_median_pct,
round(quantileDeterministicIf(0.5)(100 * (close_fwd / close - 1), toYYYYMMDD(date), is_record = 0), 1) AS other_median_pct,
round(100 * countIf(is_record = 1 AND close_fwd > close) / countIf(is_record = 1), 1) AS record_positive_pct,
round(100 * countIf(is_record = 0 AND close_fwd > close) / countIf(is_record = 0), 1) AS other_positive_pct
FROM unpivoted
WHERE close_fwd > 0
GROUP BY horizon
HAVING countIf(is_record = 1) > 0 AND countIf(is_record = 0) > 0
ORDER BY horizon