Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

Backtest-এর Confidence Band কীভাবে তৈরি ও পড়বেন

একটি equity curve কেবল একটি নমুনা। Backtest Sharpe-এর bootstrap confidence interval প্রায়ই এত বিস্তৃত হয় যে zero তার মধ্যে পড়ে। এটি তৈরি ও পড়ার পদ্ধতি জানুন।

একটি backtest confidence interval একটি প্রশ্নের উত্তর দেয়: equity curve-এর কতটা কৌশলের ফল, আর কতটা নির্দিষ্ট সেই ঐতিহাসিক সময়পর্বের ফল, যার ওপর কৌশলটি চালানো হয়েছে। Backtest bootstrap করতে return series-টি বহুবার resample করা হয়, প্রতিটি resample-এ statistic পুনরায় গণনা করা হয়, তারপর পাওয়া ফলাফলের percentile দেখে ওই interval নির্ধারণ করা হয়। দৈনিক পর্যবেক্ষণের এক বছরের ওপর মাপা 1.4 Sharpe-এর 95 percent interval এতটাই বিস্তৃত হতে পারে যে তার মধ্যে zero-ও পড়ে। নিচের panel-গুলো এর কারণ ব্যাখ্যা করে।

কেন একটি equity curve কেবল একটি নমুনা

একটি backtest প্রতিটি পরিসংখ্যানের জন্য আপনাকে একটি করে সংখ্যা দেয়: একটি Sharpe ratio, একটি annualized return, একটি worst drawdown এবং একটি hit rate। এগুলোর কোনোটিই strategy-এর প্রকৃত মূল্য নয়। প্রতিটি সংখ্যা সীমিতসংখ্যক trading day-এর ফলাফলের ওপর তৈরি একটি estimate। একই দৈর্ঘ্যের অন্য কোনো সময়পর্ব হলে ফলাফল ভিন্ন হতে পারত।

নিচের panel-এ strategy-কে সম্পূর্ণভাবে বাদ দেওয়া হয়েছে। সেখানে সবচেয়ে সহজ সম্ভাব্য position পরিমাপ করা হয়েছে: close-to-close price return-এর ভিত্তিতে প্রতি calendar year-এ একবার করে SPY ধরে রাখা।

কুয়েরিএকটি পজিশন, এক বছর করে: ক্যালেন্ডার বছর অনুযায়ী SPY-এর বার্ষিকীকৃত 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 >= '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
Run this yourself

প্রতিটি row-তে প্রায় 250টি session রয়েছে, যা standard trading year-এর কাছাকাছি। কোনো row-তেই position-এর ধরন বদলায়নি। 2012-এ annualized Sharpe ছিল 1.06; 2025-এ তা ছিল 0.88। Chart-এ মোট 14 বছর দেখানো হয়েছে। একটি broad index এক বছর ধরে রাখলেও headline number-এ এমন পরিবর্তন ঘটতে পারে, যাকে অধিকাংশ পাঠক noise হিসেবে উপেক্ষা করবেন না। একই দৈর্ঘ্যের strategy backtest-এ অন্তত ততটাই অনিশ্চয়তা থাকে। Column-টির পেছনের annualization convention আমাদের Sharpe ratio guide-এ ব্যাখ্যা করা হয়েছে। Period return কীভাবে হিসাব করা হয়, তা রয়েছে মাসিক return কীভাবে পরিমাপ করা হয়-এ।

ব্যাকটেস্টের Sharpe-এর পরিসর কতটা প্রশস্ত?

Sharpe estimate-এর standard error মোটামুটি observations-এর সংখ্যার বর্গমূলের সঙ্গে কমে। তবে ওই সূত্রের ওপর নির্ভর না করে spread সরাসরি মাপুন। 20 বছরের trading session নির্দিষ্ট দৈর্ঘ্যের overlapping-বিহীন window-তে ভাগ করুন। প্রতিটি window-এর মধ্যে annualized Sharpe হিসাব করুন। তারপর দেখুন, ওই সংখ্যাগুলো কতটা দূরে অবস্থান করছে।

কুয়েরিঅতিব্যাপী নয় এমন SPY উইন্ডোগুলিতে পরিমাপ করা Sharpe বিচ্যুতি, 2006 থেকে 2025
প্রতিটি সংখ্যার পেছনের সঠিক 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
Run this yourself

প্রতি window-তে 21 sessions হলে, পরিমাপ করা Sharpe-এর 5th থেকে 95th percentile -3.37 থেকে 6.97 পর্যন্ত। 238টি window জুড়ে এই spread 10.34 Sharpe point। প্রতিটি window 504 sessions পর্যন্ত বাড়ালে spread কমে 1.67 point হয়। তবে এবার তা মাত্র 8টি window-এর ওপর মাপা হয়েছে। একই সঙ্গে দুটি পরিবর্তন ঘটে। Sample size বাড়লে estimate আরও নির্ভুল হয়। কিন্তু history থেকে পাওয়া independent sample-এর সংখ্যা দ্রুত কমে যায়। এই দ্বন্দ্বই পুরো আলোচনার বিষয়।

ব্যাকটেস্টের confidence interval কীভাবে bootstrap করবেন

প্রক্রিয়াটি পুরোপুরি লিখে দেওয়ার মতোই সংক্ষিপ্ত।

  1. কৌশলটির realized return series দিয়ে শুরু করুন। প্রতি period-এর জন্য একটি করে figure থাকবে, মোট Nটি।
  2. ওই তালিকা থেকে replacement সহ randomভাবে Nটি return draw করুন। কিছু return দুবার আসবে, কিছু একবারও আসবে না।
  3. নতুন sample-এর ওপর statisticটি পুনরায় হিসাব করুন।
  4. এই প্রক্রিয়া কয়েক হাজার বার পুনরাবৃত্তি করুন এবং প্রতিটি value সংরক্ষণ করুন।
  5. সংরক্ষিত valueগুলো sort করুন। 95 percent interval-এর জন্য 2.5th এবং 97.5th percentile নিন।

Step 2-এর আগে random number generator-এর seed নির্ধারণ করুন এবং প্রকাশিত interval-এর পাশে সেই seed লিখে রাখুন। Bootstrap হলো একটি Monte Carlo estimate। Seed ছাড়া দুটি run-এর শেষের digitগুলো ভিন্ন হবে। কোনো reviewer আপনার interval পুনরায় তৈরি করতে না পারলে সেটি যাচাই করার উপায় থাকবে না। একটি পুনরুৎপাদনযোগ্য backtest setup-এও একই শৃঙ্খলার কথা বলা হয়েছে।

Mean return এবং Sharpe step 2-এর মাধ্যমে সহজেই pass করে, কারণ দুটিই return list-কে একটি set হিসেবে ব্যবহার করে। Maximum drawdown তা করে না। Drawdown path-এর ক্রম অনুসরণ করে। Resample-এ return-এর ক্রম বদলে গেলে এমন worst drawdown তৈরি হতে পারে, যা প্রকৃত trade sequence-এর কোনো ক্রমেই ঘটেনি। তবু এটিতে bootstrap করা উপযোগী, যদি ফলাফলকে সঠিকভাবে চিহ্নিত করা হয়: এটি reshuffled history-গুলোর মধ্যে drawdown-এর distribution, পরবর্তী drawdown-এর forecast নয়। সংজ্ঞাটি maximum drawdown-এ দেওয়া আছে।

কেন বাজারের রিটার্নে IID bootstrap ভুল

ধাপ 2-এ ধরে নেওয়া হয়, প্রতিটি রিটার্ন স্বাধীন এবং অভিন্নভাবে বিতরণকৃত—এটাই IID assumption। দৈনিক রিটার্ন এই অনুমান ভঙ্গ করে, এবং এর ফলে interval-এর প্রস্থ বদলে যায়। চিহ্নযুক্ত রিটার্নে এক দিনের স্মৃতি সামান্যই থাকে। কিন্তু রিটার্নের magnitude একসঙ্গে cluster করে: বড় পরিবর্তনের পাশে বড় পরিবর্তন থাকে, আর শান্ত দিনগুলোও পরপর আসে।

কুয়েরিLag-one স্বতঃসহসম্পর্ক: signed returns বনাম absolute returns, 2016 থেকে 2025
প্রতিটি সংখ্যার পেছনের সঠিক 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 DESC
Run this yourself

SPY-এর ক্ষেত্রে, একই দিনগুলোতে 2514টি সেশনে পরিমাপ করা absolute daily returns-এর lag-one autocorrelation ছিল 0.366। একই দিনগুলোর signed returns-এর ক্ষেত্রে এই মান ছিল -0.133। চার্টে যেকোনো একটি নামের জন্য দুটি bar তুলনা করুন। কোনো return series shuffle করলে এই clustering নষ্ট হয়ে যায়। ফলে এই ডেটায় IID bootstrap চালালে sample যতটা সমর্থন করে, তার চেয়ে সংকীর্ণ interval পাওয়া যায়। ভুলটি সবচেয়ে অনুপযোগী দিকেই যায়: এটি কৌশলটির ফলাফলকে অতিরঞ্জিতভাবে ভালো দেখায়।

মুভিং ব্লক বুটস্ট্র্যাপ এবং ব্লকের দৈর্ঘ্য নির্বাচন

একক রিটার্নের বদলে পরস্পর-সংলগ্ন ব্লক পুনরায় নমুনা নেওয়াই সমাধান। একটি ব্লকের দৈর্ঘ্য L নির্ধারণ করুন। প্রতিস্থাপনসহ এলোমেলোভাবে Lটি পরপর রিটার্নের ব্লক নির্বাচন করুন এবং কৃত্রিম সিরিজটি N দৈর্ঘ্যের না হওয়া পর্যন্ত ব্লকগুলো জোড়া লাগান। একটি ব্লকের ভেতরের নির্ভরতা অক্ষত থাকে; রিটার্নগুলো তাদের মূল ক্রমেই থাকে। কেবল ব্লকগুলোর সংযোগস্থলগুলো কৃত্রিম।

ব্লকের দৈর্ঘ্য দুটি ত্রুটির মধ্যে ভারসাম্য তৈরি করে, এবং কোনো সেটিংই দুটিকে পুরোপুরি এড়াতে পারে না। ছোট ব্লকগুলো IID bootstrap-এর মতো আচরণ করে এবং interval-কে কম দেখায়, যা bias। বড় ব্লক বেশি নির্ভরতা ধরে রাখে, কিন্তু সেখান থেকে বেছে নেওয়ার মতো স্বতন্ত্র ব্লকের সংখ্যা কমে যায়। ফলে প্রতিটি resample একই ইতিহাসের বড় অংশ বারবার পুনরাবৃত্তি করে এবং interval নিজেই বেশি অস্থির হয়, যা variance। প্রচলিত thumb rule অনুযায়ী N-এর এক-তৃতীয়াংশ শক্তির সঙ্গে ব্লকের দৈর্ঘ্য নির্ধারণ করা হয়। এগুলোকে প্রাথমিক নির্দেশনা হিসেবে বিবেচনা করুন।

আরও কম খরচের একটি diagnostic হলো horizon বাড়ার সঙ্গে variance কীভাবে পরিবর্তিত হয় তা পরীক্ষা করা। স্বাধীনতার ক্ষেত্রে, k দিনের return sum-এর variance এক দিনের variance-এর k গুণ হয় এবং দুটির অনুপাত 1-এ থাকে।

কুয়েরিব্লক দৈর্ঘ্য অনুযায়ী variance ratio: SPY-এর variance কি independent draws-এর মতো স্কেল করে?
প্রতিটি সংখ্যার পেছনের সঠিক 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 k
Run this yourself

2 sessions-এ অনুপাতটি ছিল 0.844; 63 sessions-এ ছিল 0.584। হিসাবটি করা হয়েছিল 78টি non-overlapping block-এর ওপর। 1-এর কাছাকাছি মানের অর্থ হলো ওই horizon-এ স্বাধীন draw হলে sum যেভাবে বাড়ত, সেভাবেই বাড়ছে। 1 থেকে দূরের মানগুলো দেখায় কোন horizon-এ নির্ভরতা এখনও প্রভাব ফেলছে। ব্লকের দৈর্ঘ্যকে ওই পরিসরটি আবৃত করতে হবে। সেটি অতিক্রম করে এমন সবচেয়ে ছোট ব্লক বেছে নিন। এরপর দেখুন ব্লকের সংখ্যা কত কমে গেছে।

বাকেটভিত্তিক পুনঃনমুনা এবং প্রতিটি ইন্টারভালের পাশে পর্যবেক্ষণ সংখ্যা

বাকেটের মধ্যে, ভোলাটিলিটি রেজিম বা ক্যালেন্ডার মাস অনুযায়ী পুনঃনমুনা করলে যে শর্তটি প্রশ্নটিকে গুরুত্বপূর্ণ করে তুলেছিল, তা বজায় থাকে। দাবি যদি হয় যে কোনো কৌশল উচ্চ-ভোলাটিলিটি রেজিমে তার Sharpe অর্জন করে, তাহলে কেবল উচ্চ-ভোলাটিলিটির দিনগুলো থেকে নমুনা নিয়ে তৈরি ইন্টারভালই সেই দাবির পরীক্ষা করে। এর বিনিময়ে আসে গাণিতিক সীমাবদ্ধতা। 250টি পর্যবেক্ষণকে চারটি বাকেটে ভাগ করলে প্রতি বাকেটে প্রায় 60টি পর্যবেক্ষণ থাকে। 60টি পর্যবেক্ষণ দিয়ে করা bootstrap ইন্টারভাল পূর্ণ-নমুনার সংস্করণের তুলনায় প্রায় দ্বিগুণ প্রশস্ত হয়।

তাই প্রতিবার প্রতিটি ইন্টারভালের পাশে বাকেটের পর্যবেক্ষণ সংখ্যা উল্লেখ করুন। উপরের প্যানেলগুলোর block_count কলামে এই অভ্যাসটি স্পষ্ট দেখা যায়। নয়টি উইন্ডো থেকে নেওয়া 5th percentile এবং দুই শতাধিক উইন্ডো থেকে নেওয়া 5th percentile একই বস্তু নয়, যদিও উভয়টির মানই দুই দশমিক পর্যন্ত দেখানো হয়।

বুটস্ট্র্যাপ যা ঠিক করতে পারে না

বুটস্ট্র্যাপ একটি বিষয় পরিমাপ করে: আপনার হাতে থাকা ডেটা থেকে গণনা করা কোনো পরিসংখ্যানের sampling noise। ওই ডেটা আদৌ অর্জনযোগ্য ছিল কি না, সে বিষয়ে বুটস্ট্র্যাপ কিছু বলে না।

look-ahead bias-এ দূষিত backtest এমন একটি return series তৈরি করে, যা কখনও বাস্তবে ট্রেডযোগ্য ছিল না। সেটির ওপর বুটস্ট্র্যাপ চালালে একটি কাল্পনিক ফলাফলের চারপাশে সংকীর্ণ ও আত্মবিশ্বাসী band পাওয়া যায়। আজকের index members দিয়ে তৈরি universe-এ survivorship bias থাকে, এবং ওই universe-এর প্রতিটি resample-এও একই bias থেকে যায়। আরেকটি সমস্যা হলো selection effect: 200টি variant চালিয়ে সেরাটিকে রেখে দিলে, তার bootstrap interval শুধু ওই একটি variant-এর sampling noise বর্ণনা করে; সেটি বাছাই করতে ব্যবহৃত 199টি ফলকে উপেক্ষা করে। সৎভাবে তৈরি band কার্যকর। তবে out-of-sample data-এর বিকল্প নয়।

ডেটা ও পদ্ধতির নোট
  • Returns হলো দৈনিক bar থেকে নেওয়া close-to-close price returns। Dividends বাদ দেওয়া হয়েছে। ফলে প্রতিটি return এবং Sharpe figure-এর level dividend yield-এর আনুমানিক পরিমাণে কম দেখানো হয়েছে। তবে এই পোস্টে আলোচিত dispersion প্রায় অপরিবর্তিত থাকে।
  • Windows এবং blocks non-overlapping। তাই প্রতিটি measured statistic ইতিহাসের একটি আলাদা অংশ ব্যবহার করে। Overlapping windows ব্যবহার করলে নমুনার আপাত সংখ্যা কৃত্রিমভাবে বেড়ে যেত।
  • Quantiles-এর জন্য একটি deterministic estimator ব্যবহার করা হয়েছে। ফলে panel আবার চালালেও নতুন approximation নয়, একই percentile ফেরত আসে।
  • Autocorrelation panel-এ ছয়টি বড় নাম ব্যবহার করা হয়েছে, যেগুলোর window-এর মধ্যে কোনো share split নেই। 35 percent-এর বেশি price move হওয়া দিন বাদ দেওয়া হয়েছে। এতে price-adjustment artifact correlation-এর মধ্যে ঢোকে না।

FAQ

একটি backtest সম্পর্কে bootstrap confidence interval কী জানায়?

একই সংখ্যক সময়পর্বে একই প্রক্রিয়া আবার নমুনায়িত হলে পরিসংখ্যানটি যে মানগুলোর পরিসর নিতে পারে, এটি তার একটি সম্ভাব্য পরিসর দেয়। শূন্যকে অন্তর্ভুক্ত করা 95 শতাংশ interval-এর অর্থ হলো, কৌশলটির কোনো অতিরিক্ত edge নেই—এমন অবস্থার থেকে সেটিকে আলাদা করার জন্য নমুনাটি খুব ছোট।

কতগুলো bootstrap resample যথেষ্ট?

95 শতাংশ interval-এর জন্য কয়েক হাজার resample সাধারণত স্থিতিশীল ফল দেয়। 10,000 একটি প্রচলিত default, এবং এতে খরচও কম। আরও গভীর tail quantile-এর জন্য বেশি resample দরকার। 1,000টি draw-এর 1st percentile প্রায় 10টি মান দেখে নির্ধারণ করা হয়।

Moving block bootstrap-এ block length কত হওয়া উচিত?

সবার জন্য প্রযোজ্য কোনো নির্দিষ্ট length নেই। প্রচলিত নিয়মে sample size-এর one-third power অনুযায়ী এর পরিমাণ নির্ধারণ করা হয়। ব্যবহারিকভাবে, variance যে horizon-এ আর linear হারে বাড়ে না, সেটিও দেখা যায়। উপরের variance ratio panel এই বিষয়টি পরিমাপ করে। আপনি যে length ব্যবহার করেছেন, তা interval-এর সঙ্গে প্রকাশ করুন।

Maximum drawdown-এর জন্য কি bootstrap করা যায়?

হ্যাঁ, তবে ordering-এর একটি গুরুত্বপূর্ণ বিষয় আছে। Drawdown return-এর ক্রমের ওপর নির্ভর করে। তাই shuffle করে তৈরি resample পুনর্বিন্যস্ত ইতিহাসের drawdown দেখায়। Block bootstrap স্বল্পমেয়াদি ধারাবাহিকতাগুলো অক্ষুণ্ণ রাখে। তাই path statistics-এর জন্য এটি বেশি উপযোগী।

Bootstrap কি overfitting সংশোধন করে?

না। এটি একটি return series-এর ভেতরের sampling noise পরিমাপ করে। Look-ahead bias এবং বহু variant পরীক্ষা করার ফলে তৈরি selection effect—দুটিই এর আওতার বাইরে থাকে।


এখানকার প্রতিটি panel-এর সঙ্গে সেটি তৈরি করা SQL দেওয়া আছে। ticker বা window length পরিবর্তন করে Strasmore terminal-এ নিজে query চালান।

#backtesting#bootstrap#statistics#confidence intervals#sharpe ratio