Backtestसाठी विश्वास अंतर कसे तयार करावे
एक equity curve म्हणजे एकच नमुना. Backtest Sharpeवरील bootstrap विश्वास अंतर अनेकदा शून्याचा समावेश करण्याइतके विस्तृत असते. ते तयार करून वाचण्याची पद्धत येथे दिली आहे.
Backtest confidence interval एका प्रश्नाचे उत्तर देते: equity curveपैकी किती भाग धोरणामुळे आहे आणि किती भाग ज्या ऐतिहासिक कालखंडावर ते चालवले गेले त्या विशिष्ट नमुन्यामुळे आहे? Backtestचे bootstrapping करण्यासाठी return seriesमधून अनेकदा resample घेतले जातात, प्रत्येक resampleवर statistic पुन्हा मोजला जातो आणि मिळालेल्या परिणामांच्या percentilesवरून हा interval ठरवला जातो. एका वर्षातील daily observationsवर मोजलेला Sharpe 1.4 याचा 95 percent interval इतका विस्तृत असू शकतो की त्यात zeroचाही समावेश होतो. खालील panels असे का होते, हे मोजतात.
एक equity curve म्हणजे एकच नमुना का
Backtest प्रत्येक statisticसाठी एकच आकडा देते: एक Sharpe ratio, एक annualized return, एक worst drawdown आणि एक hit rate. यापैकी कोणताही आकडा strategyचे खरे मूल्य दर्शवत नाही. प्रत्येक आकडा हा मर्यादित trading daysवर आधारित अंदाज असतो. त्याच लांबीचा दुसरा कालखंड घेतला असता वेगळे prints दिसले असते.
खालील panel strategyचा प्रश्न पूर्णपणे बाजूला ठेवतो. Close-to-close price returnsच्या आधारे, प्रत्येक वेळी एक calendar year SPY धारण केल्यास काय होते, हे तो मोजतो.
प्रत्येक आकड्यामागील अचूक SQL
WITH daily AS
(
SELECT
date,
toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2011-12-01'
AND date < '2026-01-01'
)
SELECT
toString(toYear(date)) AS year,
count() AS obs_count,
round(avg(ret) * 252 * 100, 2) AS ann_return_pct,
round(stddevSamp(ret) * sqrt(252) * 100, 2) AS ann_vol_pct,
round(avg(ret) / stddevSamp(ret) * sqrt(252), 2) AS sharpe_ratio
FROM daily
WHERE date >= '2012-01-01'
AND ret IS NOT NULL
GROUP BY year
ORDER BY yearप्रत्येक rowमध्ये सुमारे 250 sessions आहेत. हे standard trading yearच्या जवळपास आहे. या कोणत्याही कालखंडात positionमध्ये बदल झाला नाही. 2012मध्ये annualized Sharpe 1.06 इतका होता; 2025मध्ये तो 0.88 इतका होता. Chartवर 14 years दाखवले आहेत. Broad index एक वर्ष धारण केल्याने headline numberमध्ये होणारा बदल बहुतेक वाचक noise म्हणून दुर्लक्षित करणार नाहीत इतका मोठा असतो. त्याच लांबीच्या strategy backtestमध्ये किमान तेवढीच अनिश्चितता येते. या columnमागील annualization convention आमच्या Sharpe ratio मार्गदर्शकमध्ये दिली आहे. Period-returnची मोजणी monthly returns कशा मोजल्या जातात येथे स्पष्ट केली आहे.
बॅकटेस्टमधील Sharpeभोवतीची पट्टी किती रुंद असते?
Sharpeच्या अंदाजाची standard error निरीक्षणांच्या संख्येच्या वर्गमूळाच्या प्रमाणात साधारणपणे कमी होते. मात्र त्या सूत्रावर अवलंबून राहण्याऐवजी spread थेट मोजा. वीस वर्षांतील trading sessions निश्चित लांबीच्या, एकमेकांवर न येणाऱ्या windowsमध्ये विभागा. प्रत्येक windowमध्ये annualized Sharpe मोजा आणि हे आकडे एकमेकांपासून किती दूर आहेत ते पाहा.
प्रत्येक आकड्यामागील अचूक SQL
WITH daily AS
(
SELECT
date,
toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2005-12-01'
AND date < '2026-01-01'
),
indexed AS
(
SELECT
ret,
row_number() OVER (ORDER BY date) AS i
FROM daily
WHERE date >= '2006-01-01'
AND ret IS NOT NULL
),
blocks AS
(
SELECT
w,
intDiv(i, w) AS blk,
count() AS n,
avg(ret) / nullIf(stddevSamp(ret), 0) * sqrt(252) AS sharpe
FROM indexed
CROSS JOIN (SELECT arrayJoin([21, 63, 126, 252, 504]) AS w) AS ws
GROUP BY w, blk
HAVING n = w
)
SELECT
concat(toString(w), ' sessions') AS horizon,
count() AS block_count,
round(quantileDeterministic(0.05)(sharpe, blk), 2) AS sharpe_p05,
round(quantileDeterministic(0.50)(sharpe, blk), 2) AS sharpe_p50,
round(quantileDeterministic(0.95)(sharpe, blk), 2) AS sharpe_p95,
round(quantileDeterministic(0.95)(sharpe, blk)
- quantileDeterministic(0.05)(sharpe, blk), 2) AS sharpe_band_width
FROM blocks
WHERE sharpe IS NOT NULL
GROUP BY w
ORDER BY wप्रत्येक windowसाठी 21 sessions sessions घेतल्यास, मोजलेल्या Sharpeचा 5th ते 95th percentile -3.37 ते 6.97 इतका असतो. 238 windowsमध्ये हा spread 10.34 Sharpe points इतका असतो. प्रत्येक window 504 sessions sessionsपर्यंत वाढवल्यास spread 1.67 pointsपर्यंत कमी होतो. मात्र आता तो फक्त 8 windowsवर मोजला जातो. दोन गोष्टी एकाच वेळी बदलतात. Sample size वाढल्याने अंदाज अधिक अचूक होतो. पण इतिहासातून मिळू शकणाऱ्या independent samplesची संख्या झपाट्याने कमी होते. हाच या संपूर्ण विषयाचा केंद्रबिंदू आहे.
बॅकटेस्टच्या confidence intervalसाठी bootstrap कसे करावे
ही प्रक्रिया पूर्णपणे लिहून सांगता येईल इतकी संक्षिप्त आहे.
- रणनीतीच्या प्रत्यक्ष मिळालेल्या returnsची मालिका घ्या. प्रत्येक periodसाठी एक figure, म्हणजे एकूण N returns.
- त्या यादीतून replacementसह यादृच्छिकपणे N returns निवडा. काही returns दोनदा येतील, तर काही एकदाही येणार नाहीत.
- या resampleवर statistic पुन्हा मोजा.
- ही प्रक्रिया काही हजार वेळा करा आणि प्रत्येक value जतन करा.
- जतन केलेल्या valuesची क्रमवारी लावा आणि 95 percent intervalसाठी 2.5th व 97.5th percentiles घ्या.
Step 2पूर्वी random number generatorला seed द्या आणि प्रकाशित intervalच्या शेजारी तो seed नोंदवा. Bootstrap हा Monte Carlo estimate असतो. Seed न वापरलेल्या दोन runsमध्ये शेवटच्या digitsमध्ये फरक येतो. तसेच, तुमचा interval पुन्हा तयार करता न येणाऱ्या reviewerकडे तो तपासण्याचा कोणताही मार्ग राहत नाही. पुनरुत्पादक backtest setupमध्येही हीच शिस्त स्पष्ट केली आहे.
Mean return आणि Sharpe Step 2मधून सहजपणे जातात, कारण दोन्ही return listकडे संच म्हणून पाहतात. Maximum drawdown मात्र तसे करत नाही. Drawdownमध्ये pathचा क्रम महत्त्वाचा असतो. Returnsचा क्रम बदलणाऱ्या resampleमधून वास्तविक trade sequenceच्या कोणत्याही क्रमात न आलेला worst drawdown तयार होऊ शकतो. तरीही त्यावर bootstrapping करणे उपयुक्त आहे, परंतु outputचे स्वरूप स्पष्टपणे नमूद केले पाहिजे: तो पुन्हा क्रमबद्ध केलेल्या historiesमधील drawdownचे distribution आहे, पुढील drawdownचा forecast नाही. याची व्याख्या maximum drawdownमध्ये दिली आहे.
बाजारातील परताव्यांसाठी IID bootstrap चुकीचे का आहे
Step 2 मध्ये प्रत्येक परतावा independent आणि identically distributed आहे, म्हणजेच IID गृहीतके आहे, असे मानले जाते. दैनंदिन परतावे हे गृहीतक मोडतात आणि त्यामुळे intervalची रुंदी बदलते. Signed returnsमध्ये एका दिवसाची स्मृती फारच क्षीण असते. मात्र त्यांच्या magnitudesमध्ये clustering दिसते: मोठ्या हालचालींच्या शेजारी मोठ्या हालचाली होतात आणि शांत दिवस सलग येतात.
प्रत्येक आकड्यामागील अचूक SQL
WITH daily AS
(
SELECT
ticker,
date,
toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'MSFT', 'KO', 'XOM', 'JNJ', 'PG')
AND date >= '2015-11-01'
AND date < '2026-01-01'
),
lagged AS
(
SELECT
ticker,
date,
ret,
lagInFrame(ret, 1) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS ret_prev
FROM daily
WHERE ret IS NOT NULL
AND abs(ret) < 0.35
)
SELECT
ticker,
count() AS obs_count,
round(corr(ret, ret_prev), 3) AS return_autocorr,
round(corr(abs(ret), abs(ret_prev)), 3) AS abs_return_autocorr
FROM lagged
WHERE date >= '2016-01-01'
AND ret_prev IS NOT NULL
GROUP BY ticker
ORDER BY abs_return_autocorr DESCSPYसाठी, त्याच दिवसांवरील signed returnsच्या -0.133च्या तुलनेत 2514 sessionsमध्ये मोजलेली absolute daily returnsची lag-one autocorrelation 0.366 होती. चार्टमधील कोणत्याही नावासाठी दोन्ही barsची तुलना करा. Return seriesची फेररचना केल्यास हे clustering नष्ट होते. या डेटावर IID bootstrap चालवल्यास sampleच्या आधारे अपेक्षित असलेल्या intervalपेक्षा अरुंद interval मिळतो. ही चूक सर्वांत अयोग्य दिशेने परिणाम करते: ती strategyची कामगिरी वास्तविकतेपेक्षा चांगली दाखवते.
मूव्हिंग ब्लॉक बूटस्ट्रॅप आणि ब्लॉक लांबीची निवड
एकल returnsऐवजी सलग blocksचे पुनर्नमुना-निवड करणे हा उपाय आहे. L इतकी block length निवडा. L सलग returns असलेले blocks replacementसह यादृच्छिकपणे निवडा आणि synthetic series N इतकी होईपर्यंत ते एकामागोमाग जोडत जा. Blockमधील dependence अबाधित राहते; ते returns त्यांच्या मूळ क्रमानेच राहतात. केवळ blocksमधील जोडणी कृत्रिम असते.
Block length दोन त्रुटींमध्ये तडजोड घडवते आणि दोन्हींपासून कोणतीही setting पूर्णपणे सुटत नाही. लहान blocks IID bootstrapसारखे वागतात आणि interval कमी दाखवतात; ही bias आहे. मोठे blocks अधिक dependence जतन करतात, पण त्यातून निवडण्यासाठी वेगवेगळे blocks कमी उरतात. त्यामुळे प्रत्येक resample त्याच इतिहासाचे मोठे भाग पुन्हा पुन्हा घेतो आणि interval स्वतः noisy होतो; ही variance आहे. प्रकाशित thumb rules block length ही Nच्या one-third powerनुसार वाढवतात. त्यांना सुरुवातीचे बिंदू समजा.
अधिक स्वस्त diagnostic म्हणजे horizonनुसार variance कसा बदलतो हे तपासणे. Independence असल्यास k-day return sumचा variance हा one-day varianceच्या k पट असतो आणि या दोन्हींचे ratio 1 असते.
प्रत्येक आकड्यामागील अचूक SQL
WITH daily AS
(
SELECT
date,
toFloat64(close) / nullIf(lagInFrame(toFloat64(close), 1)
OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 0) - 1 AS ret
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2005-12-01'
AND date < '2026-01-01'
),
indexed AS
(
SELECT
ret,
row_number() OVER (ORDER BY date) AS i
FROM daily
WHERE date >= '2006-01-01'
AND ret IS NOT NULL
),
base AS
(
SELECT varSamp(ret) AS var_1d FROM indexed
),
blocks AS
(
SELECT
k,
intDiv(i, k) AS blk,
count() AS n,
sum(ret) AS block_ret
FROM indexed
CROSS JOIN (SELECT arrayJoin([2, 3, 5, 10, 21, 42, 63]) AS k) AS ks
GROUP BY k, blk
HAVING n = k
)
SELECT
concat(toString(k), ' sessions') AS block_length,
count() AS block_count,
round(varSamp(block_ret) / (k * any(var_1d)), 3) AS variance_ratio
FROM blocks
CROSS JOIN base
GROUP BY k
ORDER BY k2 sessions येथे ratio 0.844 इतका मोजला गेला; 63 sessions येथे तो 0.584 इतका मोजला गेला. ही गणना 78 non-overlapping blocksवर करण्यात आली. 1च्या जवळील values म्हणजे त्या horizonवर independent drawsप्रमाणे sum scale होतो. 1पासून दूर असलेल्या valuesमधून dependence अजूनही परिणाम करत असल्याचे दिसते. Block lengthने हीच range व्यापली पाहिजे. ही range पार करणारा सर्वांत लहान block निवडा आणि त्यानंतर block count किती राहिला आहे ते पाहा.
प्रत्येक intervalच्या शेजारी bucketमधील निरीक्षणांची संख्या
Bucketsमध्ये, volatility regimeनुसार किंवा calendar monthनुसार resampling केल्यास प्रश्न महत्त्वाचा बनवणारी conditioning कायम राहते. एखादी strategy high-volatility regimesमध्ये आपला Sharpe मिळवते, असा दावा असल्यास केवळ high-volatility daysमधून निवड करून तयार केलेला intervalच त्या दाव्याची चाचणी करतो. मात्र यासाठी arithmeticची किंमत मोजावी लागते. 250 observationsचे चार bucketsमध्ये विभाजन केल्यास प्रत्येक bucketमध्ये साधारण 60 observations उरतात. 60-observation bootstrapमधून मिळणारा interval full-sample आवृत्तीपेक्षा साधारण दुप्पट रुंद असतो.
म्हणून प्रत्येक वेळी, प्रत्येक intervalच्या शेजारी bucketमधील observationsची संख्या द्या. वरील panelsमधील block_count column ही पद्धत स्पष्टपणे दाखवतो: नऊ windowsमधून काढलेला 5th percentile हा दोनशे windowsमधून काढलेल्या 5th percentileपेक्षा वेगळा असतो, जरी दोन्ही आकडे दोन decimal placesपर्यंत समान दिसत असले तरी.
बूटस्ट्रॅप काय दुरुस्त करू शकत नाही
बूटस्ट्रॅप एका गोष्टीचे मापन करतो: तुमच्याकडील डेटावरून मोजलेल्या सांख्यिकीतील sampling noise. तो डेटा प्रत्यक्षात कधी उपलब्ध होऊ शकला असता का, याबाबत तो काही सांगत नाही.
look-ahead biasमुळे दूषित झालेल्या backtestमधून असा return series तयार होतो, ज्यावर प्रत्यक्ष व्यवहार करणे कधीच शक्य नव्हते. त्यावर केलेला bootstrap मग काल्पनिक मालिकेभोवती घट्ट आणि आत्मविश्वासपूर्ण band देतो. आजच्या index membersवर आधारित universeमध्ये survivorship bias असतो आणि त्या universeमधील प्रत्येक resampleमध्ये तोच bias येतो. तिसरी त्रुटी म्हणजे selection effect. 200 variants चालवून त्यातील सर्वोत्तम variant ठेवला, तर त्याचा bootstrap interval केवळ त्या एका variantमधील sampling noise दाखवतो. तो variant निवडण्यासाठी केलेल्या 199 निवडींकडे तो दुर्लक्ष करतो. प्रामाणिक bands उपयुक्त असतात. मात्र ते out-of-sample dataला पर्याय नाहीत.
डेटाविषयक नोंदी आणि पद्धत
- Returns हे daily barsमधील close-to-close price returns आहेत. Dividends वगळले आहेत. त्यामुळे प्रत्येक return आणि Sharpe figureची पातळी dividend yieldच्या अंदाजे प्रमाणात कमी दिसते. या लेखातील dispersionवर त्याचा परिणाम जवळपास होत नाही.
- Windows आणि blocks overlapping नाहीत. त्यामुळे प्रत्येक measured statistic इतिहासातील वेगळ्या sliceवर आधारित आहे. Overlapping windowsमुळे samplesची दिसणारी संख्या कृत्रिमरीत्या वाढली असती.
- Quantilesसाठी deterministic estimator वापरला आहे. त्यामुळे panel पुन्हा चालवल्यावर नव्याने approximation मिळण्याऐवजी तोच percentile मिळतो.
- Autocorrelation panelमध्ये windowच्या आत share split नसलेली सहा मोठी नावे वापरली आहेत. 35 टक्क्यांपेक्षा अधिक हालचाल असलेला कोणताही दिवस वगळला आहे. त्यामुळे price-adjustment artifacts correlationमध्ये येत नाहीत.
वारंवार विचारले जाणारे प्रश्न
बॅकटेस्टबद्दल bootstrap confidence interval काय सांगते?
समान कालावधींच्या संख्येत हीच प्रक्रिया पुन्हा नमुना घेतल्यास संबंधित statistic कोणत्या मूल्यांच्या मर्यादेत येऊ शकतो, हे ते दर्शवते. शून्याचा समावेश असलेला 95 percent interval म्हणजे strategy आणि कोणताही edge नसलेली स्थिती यांच्यात फरक करण्यासाठी नमुना अपुरा आहे.
किती bootstrap resamples पुरेसे असतात?
95 percent intervalसाठी काही हजार resamples साधारणतः स्थिर परिणाम देतात. 10,000 ही कमी खर्चाची आणि प्रचलित default संख्या आहे. अधिक खोल tail quantilesसाठी जास्त resamples आवश्यक असतात. 1,000 drawsमधील 1st percentile साधारणतः दहा मूल्यांच्या आसपास वाचला जातो.
moving block bootstrapमध्ये block length किती असावी?
सर्व परिस्थितींना लागू होईल अशी एकच योग्य length नाही. सामान्य नियमांनुसार ती sample sizeच्या one-third powerच्या प्रमाणात ठेवली जाते. व्यावहारिक तपासणी म्हणून variance रेषीय पद्धतीने वाढणे थांबते तो horizon पाहता येतो; वरील variance ratio panel हे मोजते. वापरलेली length intervalसोबत प्रकाशित करा.
maximum drawdownसाठी bootstrap करता येतो का?
होय, पण क्रमाशी संबंधित एक महत्त्वाची बाब आहे. Drawdown हा returnsच्या क्रमावर अवलंबून असतो. त्यामुळे shuffling करून तयार केलेल्या resampleमधून पुनर्क्रमित इतिहासाचा drawdown मिळतो. Block bootstrapमध्ये सलग अल्पकालीन हालचाली कायम राहतात. त्यामुळे path statisticsसाठी ते अधिक योग्य साधन आहे.
bootstrap overfitting दुरुस्त करते का?
नाही. ते एका return seriesमधील sampling noise मोजते. Look-ahead bias आणि अनेक variants तपासल्यामुळे निर्माण होणारा selection effect हे दोन्ही त्याच्या निरीक्षणाच्या कक्षेबाहेर राहतात.
येथील प्रत्येक panel तयार करणारे SQL सोबत दिलेले आहे. ticker किंवा window length बदला आणि Strasmore terminalवर ते स्वतः run करा.