11 सितंबर 2026 · पुनरुत्पादकता

वही कोड, वही डेटा, दो अलग Sharpe: आपके बैकटेस्ट के लिए नॉनडिटरमिनिज़्म ऑडिट

वही कोड, वही डेटा, दो अलग Sharpe: आपके बैकटेस्ट के लिए नॉनडिटरमिनिज़्म ऑडिट

वही कमिट, वही parquet फ़ाइलें, वही मशीन, एक घंटे के अंतर पर 2 रन। Sharpe 1.34 और Sharpe 1.19। कुल रिटर्न 41.2% और 37.8%। ट्रेडों की संख्या 1,418 और 1,421। और अलग होने से पहले दोनों इक्विटी कर्व लगातार 11,205 बार तक छपे हुए हर दशमलव अंक पर एक जैसे थे।

0.15Sharpe का अंतर, इनपुट एक जैसे
3 / 1,418अलग रहे ट्रेड
11,205अलगाव से पहले एक जैसे बार

पहले 3 अंक परेशान करते हैं। आखिरी वाला दिलचस्प है, क्योंकि उससे पता चलता है कि समस्या पूरे रन में फैली हुई कोई ढीली-ढाली floating point गणना नहीं है। एक खास बार पर कुछ अलग हुआ, और उसके बाद बाकी फर्क चक्रवृद्धि होता गया। इस पोस्ट में हम उस बार को खोजेंगे और देखेंगे कि उस 0.15 का आकार आपके अब तक चलाए हर parameter sweep पर क्या असर डालता है।

रणनीति: सबसे अधिक वॉल्यूम वाले 30 USDⓈ-M perps पर cross-sectional momentum, हर 4 घंटे में rebalance, 12 घंटे के रिटर्न के आधार पर सबसे ऊपर के 5 को long और सबसे नीचे के 5 को short, 18 महीने का इतिहास, हर leg पर fees और funding।

वह बार खोजना जहाँ रन अलग हुए

अगर आप हर बार की इक्विटी पूरी precision पर लॉग करते हैं, तो इसमें करीब 4 मिनट लगते हैं। दोनों रन को (bar_index, equity, open_positions_hash) की CSV में निकालें, उन्हें लोड करें और पहला ऐसा index ढूँढ़ें जहाँ वे अलग हों। हमने यह स्क्रिप्ट पहले किसी दूसरे बग के लिए लिखी थी; उसी वजह से इसमें मेरी पूरी सुबह नहीं गई।

बार 11,206, 2025-03-14T08:00 UTC। पिछले बार तक इक्विटी 13 significant figures तक एक जैसी थी। बार 11,206 पर position hashes अलग हैं: रन A में SOL long है, रन B में AVAX long। दिशा एक, notional एक, symbol अलग। इसके बाद का सब कुछ इसी का नतीजा है।

तो मैंने उस बार के ranking inputs दोनों रन में छापे। वे एक जैसे थे। बाइट-दर-बाइट एक जैसे, वही 30 symbols, वही 30 scores। 2 scores 0.0 थे। बिल्कुल शून्य, दोनों के, क्योंकि दोनों नामों के lookback window में एक 4-hour bar पर कोई ट्रेड नहीं हुआ था; इसलिए close-over-close का मान एक आया और log का मान शून्य। यह rounding करके शून्य नहीं बना था। वह सचमुच शून्य था।

रैंक 5 पर 2 symbols बराबरी पर थे। चयन में सबसे ऊपर के 5 लिए गए। इन दोनों में से कौन चुना जाएगा, यह इस बात पर निर्भर था कि sort ने उन्हें किस क्रम में छोड़ा; और sort वैसा काम नहीं कर रहा था जैसा मैंने माना था।

बराबरी, अस्थिर sort और वह बात जिसे मैं 2 साल से नज़रअंदाज़ कर रहा था

रैंकिंग एक grouped aggregation से होकर चली, और उसे डेटा देने वाला frame symbols के एक set पर चलाए गए dict comprehension से आया था। यह set हर बार async fetch से दोबारा बनाया जाता था। hash seed के साथ set का iteration order बदलता है, और जब तक आप PYTHONHASHSEED को तय न करें, Python हर process के लिए string hash seed को बेतरतीब बनाता है। इसलिए sort से पहले पंक्तियों का क्रम रन-दर-रन अलग था; और बराबर key पर non-stable sort लगाने से बराबरी का फैसला भी अलग हुआ।

ऐसी बराबरियाँ कोई अनोखी घटना नहीं हैं। ये संरचना में ही मौजूद हैं। जहाँ भी कोई feature अपनी सीमा तक पहुँचता या clip होता है, वहाँ बिल्कुल समान मान बनते हैं: zero-volume bars से returns ठीक शून्य आते हैं, clipped z-score ±3.0 पर टिक जाता है, कम अलग-अलग मानों वाला rank-transform दर्जनों बराबरियाँ बनाता है, और boolean filter में पास होने वाले हर मान का score 1.0 होता है। 4-hour bars पर 18 महीनों के दौरान इस रन में चयन की सीमा पर बराबरी वाले 47 बार थे। उनमें से 3 ने चुने गए basket को बदल दिया। बाकी में बराबरी उन 2 नामों के बीच थी जो दोनों पहले से चुने गए थे या दोनों बाहर थे।

करीब 1 दिन तक मुझे यकीन था कि data loader में non-determinism है, क्योंकि वही रोमांचक जवाब लगता है। ऐसा नहीं था। कभी होता ही नहीं। बात बस एक set, एक बराबरी और sort की स्थिरता के बारे में उस धारणा की थी जिसे किसी ने लिखा तक नहीं था।

3 ट्रेड 0.15 Sharpe के लायक क्यों हैं

यहीं लोग आपत्ति करते हैं। जवाब यह है कि equity-fraction sizing वाला बैकटेस्ट path-dependent system होता है। हर leg में मौजूदा इक्विटी का 8% लगाने का मतलब है कि बार n पर इक्विटी का अंतर, बार n से आगे हर notional में अंतर बन जाता है।

पहले divergence का अपना असर बहुत कम था। रन B के AVAX leg में 9 घंटे में 2.1% का नुकसान हुआ; रन A के SOL leg में 0.4% का लाभ। उस ट्रेड के बाद इक्विटी का अंतर: 0.21%। मामूली। लेकिन इसके बाद दोनों रन एक ही रणनीति नहीं रह जाते। उनकी पोज़िशन का आकार थोड़ा अलग रहता है, इसलिए funding accrual भी थोड़ा अलग होता है; और बाद में सीमा पर आई 2 बराबरियों का फैसला फिर अलग हुआ, क्योंकि उन्हें मिलने वाले scores अब थोड़ी अलग held positions से आ रहे थे। उनमें से एक 2025-03-27 को आया, उस 6 दिन के trend से 1 दिन पहले जिससे रन के कुल PnL का करीब 1/3 बना। रन A पूरे उतार-चढ़ाव में शामिल रहा; रन B ने 1 rebalance देर से प्रवेश किया।

रिटर्न का अंतर: 3.4 points। Sharpe का अंतर रिटर्न के अंतर से बड़ा है, क्योंकि रन B के बदले हुए ट्रेड ज़्यादा उतार-चढ़ाव वाले दौर से मेल खाए; इससे अंश घटा और हर बढ़ा। छोटा कारण, 2 बढ़ाने वाले कारक।

अगर आपकी sizing fixed-notional है और आपकी entries मौजूदा holdings पर निर्भर नहीं करतीं, तो आप इस असर से काफी हद तक सुरक्षित हैं। दिलचस्प रणनीतियों में आम तौर पर दोनों में से कोई बात सही नहीं होती।

वे 5 जगहें जहाँ यह सचमुच होता है

स्रोतलक्षणसमाधान
बिना तय PYTHONHASHSEED वाला seed, जहाँ set/dict का iteration order sort को प्रभावित करता हैरन के बीच बराबरी का फैसला बदलता है; किसी खास बार पर पहला divergenceseed तय करें; बराबरी का फैसला स्थिर रखने के लिए स्पष्ट secondary key (symbol) से sort करें
बराबर key पर non-stable sort (quicksort NumPy/pandas का default)ऊपर जैसा ही; seed तय करने के बाद भी बना रहता हैkind="stable", या key को total order वाला बनाएँ
bootstrap, train/test shuffle या synthetic fill jitter में बिना seed वाला RNGपूरे रन में बदलाव, अलगाव का कोई साफ़ शुरुआती बिंदु नहींहर component के लिए 1 स्पष्ट seed रखें और उसे run manifest में लॉग करें
Parallel float reduction (धागों की संख्या पर निर्भर summation order)आखिरी कुछ bits में अंतर; आम तौर पर नुकसानरहित, जब तक वे threshold comparison पार न करा देंरिसर्च रन में thread counts तय रखें; निर्णय की सीमा पर floats की तुलना कभी == से न करें
बिना तय library versionsआज दोहराया जा सकता है, नवंबर में नहींडेटा snapshot hash के साथ manifest में lockfile hash भी रखें

चौथी पंक्ति का असर उतनी बार नहीं होता जितना लोग डरते हैं; पहली पंक्ति बार-बार समस्या बनती है।

बिट-दर-बिट पुनरुत्पादन एक औज़ार है, कोई सद्गुण नहीं

आपको determinism इसलिए चाहिए ताकि एक पंक्ति बदलने पर इक्विटी कर्व में आया अंतर उसी पंक्ति से जोड़ा जा सके। बस यही वजह है। अब Stratmill पर हर agent run ऐसा manifest लिखता है जिसमें डेटा snapshot hash, lockfile hash और हर seed दर्ज होता है; और जो rerun पिछला कर्व बिट-दर-बिट दोबारा न बनाए, वह असफल build है, कोई कौतूहल नहीं।

लेकिन जब आप नतीजा दोहरा सकते हों, तो जान-बूझकर उसे बदलकर भी देखें। उस चीज़ को 64 seeds के साथ 64 बार चलाएँ और फैलाव देखें:

Jitter band। वही रणनीति, वही डेटा, बराबरी का फैसला और fill order बदलने वाले 64 seeded permutations। Sharpe p5 1.12, median 1.27, p95 1.41। Band की चौड़ाई 0.29।

अब parameter sweep पर लौटें। सबसे अच्छे config का score 1.46 था। 96 में 40वें स्थान पर रहे config का score 1.31 था। इनके बीच का अंतर 0.15 है, जो band की आधी चौड़ाई है। Sweep ने इन 2 configs को रैंक नहीं किया। उसने हर एक के distribution से 1-1 नतीजा लिया और उन नतीजों को क्रम में लगा दिया।

इस नज़रिए ने हमारे चुनाव करने का तरीका बदल दिया। Sweep का नतीजा तभी ranking कहलाता है जब configs के बीच का अंतर किसी एक config के jitter से बड़ा हो। 1,400 ट्रेड वाली path-dependent रणनीति में jitter इतना बड़ा होता है कि leaderboard के ऊपरी 1/3 को बराबरी पर ला देता है। ऐसा हो, तो ऐसी चीज़ के आधार पर चुनें जिसे band छिपा न सके: कम turnover, कम parameters, ऐसी cost assumption जिसका बचाव आप किसी संशय करने वाले के सामने कर सकें, या उस walk-forward fold में बेहतर व्यवहार जो आपको सबसे कम पसंद हो। ये असली tiebreakers हैं। 0.15 Sharpe की बढ़त नहीं।

एक आखिरी काम, किसी भी नतीजे पर भरोसा करने से पहले। अपना बैकटेस्ट अभी 2 बार चलाएँ, हर बार की इक्विटी का diff निकालें और पता करें कि आप बिट-दर-बिट एक जैसे नतीजों वाले समूह में हैं या 0.15 वाले समूह में। यह 15 मिनट का प्रयोग है और इससे पता चलता है कि आपका कितना रिसर्च इतिहास रणनीति को माप रहा था और कितना hash seed को।

डिटरमिनिज़्मबैकटेस्टिंगडेटा इंजीनियरिंगओवरफिटिंगpython
साझा करेंXLinkedInFacebookRedditHacker NewsWhatsAppTelegramEmail
← सभी पोस्ट