التحيز التطلعي في اختبار المحاكاة يتنكّر في شيفرة عادية: اختبار إفساد المستقبل يفضحه
التحيز التطلعي هو الحالة الافتراضية لأي شيفرة اختبار محاكاة غير حذرة، لا حالة حدّية نادرة، وهو ينجو من المراجعة لأنه يبدو مجرد شيفرة pandas عادية. إليك اختبار السببية الذي صار الآن بوابة تمر منها كل استراتيجية: أفسِد كل سعر بعد تاريخ معين، ثم تحقق من أن لا شيء محسوب قبل ذلك التاريخ يتغير. فإذا تحرك الماضي حين تسمّم المستقبل، فالاستراتيجية تقرأ ما هو قادم.

أول مرة صدّقت فيها حقاً أن التحيز التطلعي (Look-ahead bias) هو الحالة الافتراضية لشيفرتي، لا حادثاً نادراً، كانت أثناء تدقيق زاوية واحدة من Titan، نظامي متعدد الاستراتيجيات الذي يتداول بأموال حقيقية، تحرّكها الحالة السوقية (Regime). كنت أبحث عن تسرّب واحد، فوجدت الخطأ نفسه في أربعة ملفات منفصلة. كان كل ملف منها يضرب إشارة في عائد وقع بالفعل. وكان كل واحد منها يبدو شيفرة pandas عادية تماماً. ومجتمعةً، حوّلت هذه الملفات استراتيجية راكدة إلى استراتيجية باهرة، ولم يُرصَد واحد منها في المراجعة. وهذا ما لا يخبرك به أحد: التحيز التطلعي ليس نمط فشل غريباً تتعثر به بين الحين والآخر. إنه ما تفعله الشيفرة غير الحذرة بشكل افتراضي، ويختبئ في وضح النهار لأنه ينتج منحنى يبدو تماماً كالنجاح.
هذا المقال عن السبب البنيوي وراء ذلك، والأثواب التي يرتديها هذا العيب، والاختبار الوحيد الذي أثق به الآن أكثر من أي قدر من القراءة المتأنية: أفسِد المستقبل، وتحقّق من أن الماضي لا يتزحزح.
التحيز التطلعي هو الافتراضي، لا الاستثناء
اختبار المحاكاة التاريخية (Backtest) هو محاكاة لقرارات تُتخذ عبر الزمن بالاعتماد فقط على المعلومات المتاحة في كل لحظة. لكن الأدوات التي نكتب بها ذلك الاختبار، أي pandas وnumpy وإطار بيانات (Dataframe) يحمل التاريخ كله في الذاكرة دفعة واحدة، لا تملك مفهوماً لكلمة "الآن". فكل عمود فيها يحتوي على المستقبل أصلاً: x.mean() يرى كل شمعة، وmomentum.idxmax() يرى إغلاق اليوم، ودالة .where() تُحاذي سلسلتين دون أن تهتم بأيّهما كانت معلومة أولاً. الآلية كلها مبنية لترى كل شيء دفعة واحدة، لذا فإن الشيء الطبيعي الذي تكتبه أصابعك هو بالضبط الشيء الذي يسترق النظر إلى الأمام. عليك أن تسبح عمداً عكس تيار المكتبة لتكتب شيفرة سببية، وإن لم تفعل، تسرّب المستقبل إليك من تلقاء نفسه ودون أي مقابل.
هذا هو سبب نجاة التحيز التطلعي من المراجعة. فهو لا يتنكّر في هيئة شيء مريب، بل يتنكّر في هيئة كفاءة. والفارق بين اختبار محاكاة مسرّب وآخر صادق هو .shift(1) واحدة مدفونة في منتصف سطر يبدو، لولاها، معقولاً تماماً. ولا تتوقف عندها عين المراجع، لأنه لا يوجد فيها ما يستوقفها.
Chart
One .shift(1) is the whole difference
The same-bar collect, where a position earns the very bar that chose it, manufactures a plausible but fake curve; the .shift(1) version on the identical data is flat. Illustrative and sanitised.
الثوب الذي أطلقته إلى الإنتاج أكثر من غيره هو "قطف الشمعة نفسها" (Same-Bar Collect). تُحدّد مركزاً بالاعتماد على الشمعة t، ثم تجني عائد تلك الشمعة t نفسها به:
# خطأ: يُختار `winner` بالاعتماد على close[t]؛ و`ret` هو العائد الذي يدخل عند close[t].
winner = momentum.idxmax(axis=1) # معلومة فقط عند إغلاق الشمعة t
strat = ret.where(columns == winner) # لكن ret وقع بالفعل عند إغلاق الشمعة t
لقد اختير المركز بمعلومة تتضمن الشمعة ذاتها التي يربح منها لاحقاً. ولأن الزخم (Momentum) مترابط ذاتياً (Autocorrelated)، فإن هذا يُلفّق منحنى رأس مال باهراً ومقنعاً ومزيفاً بالكامل. والحل إزاحة زمنية واحدة (Lag):
winner_lag = winner.shift(1) # القرار يُبنى على معلومة الأمس
strat = ret.where(columns == winner_lag) # العائد المجني هو عائد اليوم
حرف واحد يفصل بين رخصة لطباعة المال وخط مسطّح لا يتحرك. وهذه هي المشكلة بأكملها، مكثّفة في حرف واحد.
الأثواب الأخرى
قطف الشمعة نفسها هو الأصخب بين أثوابه، لكن للتحيز التطلعي خزانة أثواب كاملة. وهذه ثلاثة أخرى أتحقق منها بحكم العادة:
تطبيع السلسلة كاملةً (Full-series normalisation). توحيد خاصية (Feature) إحصائياً أمر روتيني، وصيغته الروتينية تسترق النظر إلى الأمام ببنيتها ذاتها:
z = (x - x.mean()) / x.std() # المتوسط والانحراف المعياري محسوبان على السلسلة كاملةً
في كل شمعة تاريخية، تكون تلك الدرجة المعيارية (Z-score) قد عرفت مسبقاً متوسط وانحراف بيانات لم تقع بعد. وهي لا تلمس عائداً مباشرة أبداً، وهذا بالضبط ما يجعلها خبيثة: فهي تُسمّم الخاصية نفسها، وينتقل الضرر إلى كل ما يتلو ذلك من إشارات تتغذى على تلك الخاصية. والعلاج نافذة سببية أو نافذة متوسّعة (Expanding Window)، أو تجميد الإحصاءات على شريحة العينة الداخلية (In-sample) وتطبيقها كما هي على العينة الخارجية (Out-of-sample).
الإشارة اليومية المُعبَّأة أمامياً على إطار أقصر (Forward-fill). تحسب إشارة على شموع يومية، ثم تُلحقها بإطار زمني بالساعة وتُعبّئها للأمام. افعل ذلك بإهمال، وستصبح القيمة اليومية ليوم معين متاحة في الساعة الأولى من ذلك اليوم، قبل ساعات من إغلاق الشمعة اليومية التي حُسبت منها أصلاً. الإشارة تُسافر عبر الزمن عائدة إلى الصباح. وما يجعل هذه الحيلة خبيثة أن التسرّب يُقاس بالساعات، لا يظهر على أي رسم بياني، ولا يتكشّف إلا حين تُقارن الطابع الزمني الدقيق الذي أصبحت فيه الشمعة اليومية معلومة بالطابع الزمني الذي بدأت عنده التصرف بناءً عليها.
مُنعِّم حيث كنت تحتاج إلى مُرشِّح. متوسط متحرك مُتمركز، أو مُنعِّم Savitzky-Golay، أو مرشّح Hodrick-Prescott غير المتصل بالزمن الفعلي، أو تمريرة scipy.signal.filtfilt: كل واحد من هذه يستخدم عينات مستقبلية لحساب القيمة عند النقطة الحالية. وهذه الأدوات رائعة لرسم خط نظيف على مخطط للماضي، وكارثية حين تُستخدم كإشارة تعمل في الوقت الفعلي، لأن القيمة "الحالية" تعتمد حرفياً على نقاط تقع إلى يمينها. وإذا كانت مكتبة المؤشرات لديك تقدّم مُنعِّماً ثنائي الاتجاه، فافترض أنه يتطلّع إلى الأمام حتى تُثبت أن تنفيذه سببي.
لاحظ القاسم المشترك بينها: فلا يبدو أي منها كغش، بل يبدو كل واحد منها شيفرة عددية معقولة، بل أنيقة حتى. وهذا بالضبط سبب استحالة أن تبلغ الأمان بمجرد قراءة الشيفرة وإعادة قراءتها. أنت بحاجة إلى اختبار لا يهتم بمدى معقولية الشيفرة ظاهرياً، بل فقط بما إذا كانت المعلومة تتدفق إلى الوراء عبر الزمن.
الخلاصة القابلة للنقل: أفسِد المستقبل
إليك الاختبار الذي أريدك أن تحتفظ به. فهو لا يرتبط بإطار عمل (Framework) بعينه، ويسعه بضعة أسطر، ولا يهتم بما تفعله استراتيجيتك داخلياً. والمبدأ هو تعريف السببية بذاتها: لا يجوز لأي شيء تحسبه عند الزمن T أو قبله أن يعتمد على أي سعر بعد T. فاختبر هذا بالتحديد.
خذ سلسلة أسعارك. اختر تاريخ قطع T في مكان ما من المنتصف. احسب إشارتك، أو مركزك، أو مصفوفة خصائصك (Feature Matrix) كاملة، على البيانات النظيفة، واحتفظ بالشريحة حتى T. ثم اصنع نسخة مسمومة من السلسلة نفسها، تُفسَد فيها كل قيمة بعد T تماماً: استبدلها بتشويش عشوائي، أو اضربها في عشرة، أو اعكس ترتيبها، أو استبدلها بأصفار، أو أي شيء مختلف تماماً. أعد حساب الكمية نفسها على السلسلة المسمومة، وخذ شريحتها حتى T. ثم تحقّق من أن الشريحتين متطابقتان تماماً.
Figure
The corrupt-the-future test
A framework-agnostic causality check: nothing computed at or before T may depend on a price after T.
- 1
Pick a cut date T
somewhere in the middle of the price series
- 2
Compute on CLEAN data, keep the slice up to T
a signal, a position, or the whole feature matrix
- 3
Poison every price strictly AFTER T
multiply by noise, reverse it, or zero it
- 4
Recompute on the POISONED data, keep the slice up to T
- 5
Assert the two pre-T slices are identical
if the past moved, the leak is at the first divergent bar
It does not ask whether the code looks causal. It asks whether the answer changes when you change data that should not be visible yet.
def assert_no_lookahead(compute, prices, cut, rng):
# compute(prices) -> سلسلة/إطار مفهرس مثل prices (إشارة، أو مركز، أو خاصية)
clean = compute(prices)
poisoned = prices.copy()
future = poisoned.index > cut
poisoned.loc[future] *= (1 + rng.normal(0, 5, future.sum())) # خرّب المستقبل
dirty = compute(poisoned)
before = prices.index <= cut
# لا يجوز لأي قيمة محسوبة عند نقطة القطع أو قبلها أن تتحرك حين يتغيّر المستقبل وحده.
pd.testing.assert_series_equal(clean[before], dirty[before])
إذا اجتاز هذا التحقق، فالماضي لا يعرف المستقبل فعلاً، عند نقطة القطع هذه. أما إذا فشل، فأنت لا تملك مجرد شك، بل تسرّباً محدَّد الموقع بدقة: أول فهرس تتباعد عنده clean وdirty قبل نقطة القطع هو بالضبط الشمعة التي نزف فيها المستقبل إلى الماضي لأول مرة. شغّله على حفنة من تواريخ قطع عشوائية وعدة أساليب إفساد، واربطه بمسار التكامل المستمر (CI) بحيث يعمل على كل استراتيجية قبل ترقيتها. يكلّف أجزاء من الثانية، ومن الصعب بوحشية خداعه، لأنه لا يسأل "هل تبدو هذه الشيفرة سببية؟" بل يسأل السؤال الوحيد المهم: "هل تتغيّر الإجابة حين أُغيّر بيانات لم يكن ينبغي أن تكون مرئية بعد؟"
وجماله أنه شامل تماماً. لا يهمّه أين يكمن التسرّب: في .shift() نسيته، أو .mean() محسوبة على السلسلة كاملةً، أو تعبئة أمامية، أو مُرشِّح ثنائي الاتجاه، أو مكان ما داخل تبعية لم تقرأها يوماً. فأي مسار تصل عبره أسعار المستقبل إلى قيمة من الماضي يُفعّل الفخ نفسه.
قصة حرب: النموذج الذي أقنعني بهذا الاختبار
الحالة التي حوّلت هذا الاختبار إلى إجراء دائم في Titan كانت نموذجاً للحالة السوقية (Regime Model). كنت أفكّ ترميز حالات السوق بنموذج ماركوف الخفي (Hidden Markov Model)، واستخدمت خطوة فك الترميز خوارزمية الأمام-الخلف (Forward-Backward)، وهي مُنعِّم على طراز Viterbi ينتج التسلسل الأكثر احتمالاً للحالات انطلاقاً من سلسلة المشاهدات بأكملها. وبعبارة أخرى: الحالة السوقية المُستنتَجة ليوم معين كانت محسوبة باستخدام كل يوم أتى بعده. فحين تُقرأ دون اتصال (Offline) على بيانات تاريخية، تكون الإجابة الصحيحة وفق معيار الأرجحية القصوى (Maximum Likelihood). أما حين تُنشَر كإشارة حية، فهي تحيز تطلعي خالص، لأن تصنيف حالة اليوم اعتمد على أسعار الأسبوع التالي.
على الأرجح كنت سأُقنع نفسي بألا أرى هذا. كانت الشيفرة نموذجية تماماً، والمكتبة محترمة، وعبارة "التسلسل الأكثر احتمالاً للحالات" تبدو بالضبط كما تريد. لكنني لم أحتج إلى أن أجادل نفسي. شغّلت اختبار إفساد المستقبل على سلسلة الحالة السوقية، مُفسِداً الأسعار بعد نقطة قطع في منتصف العينة، فتغيّرت تصنيفات الحالة قبل نقطة القطع. فشل الاختبار في ثوانٍ، وأشار مباشرة إلى خطوة فك الترميز. وكان الحل التحوّل من فك الترميز المُنعَّم إلى فك ترميز مُرشَّح (Filtered) لا يشترط سوى البيانات حتى كل نقطة، وإعادة حساب كل رقم يعتمد على تلك الخطوة لاحقاً في السلسلة. ما كان لأي قدر من التحديق في استدعاء فك الترميز أن يكشف ذلك؛ بل كشفته نسخة مسمومة من البيانات فوراً.
هذا هو الدرس العام من كل تسرّب تطلعي أطلقته يوماً، بما فيها عمليات القطف الأربع من الشمعة نفسها. لم يُمسَك أي منها بإعادة قراءة الشيفرة، لأن البقعة العمياء نفسها التي كتبت الخطأ هي التي تقرأ المراجعة. سقط كل واحد منها أمام فحص خارجي آلي طرح السؤال الذي كان انتباهي قد تجاوزه بالفعل. القراءة المتأنية شرط ضروري، لكنها غير كافية. والاختبار هو ما يصون هذا الخط فعلياً.
فاحمل هذا معك من هنا: عامل أي position * return بوصفه مذنباً حتى تُثبت أن المركز كان معلوماً قبل أن تُفتح نافذة ذلك العائد بوقت كافٍ، واجعل اختبار إفساد المستقبل بوابة يجتازها كل استراتيجية قبل أن تلمس رأس المال الحقيقي. التحيز التطلعي ليس الاستثناء الذي ترتكبه شيفرتك أحياناً، بل هو الافتراضي الذي لا تفلت منه شيفرتك إلا حين تُجبرها على ذلك.
الفصل الكامل الذي نشأ منه هذا المقال، اختبار محاكاة يمكنك أن تثق به، متاح للقراءة مجاناً: يمر على الطرق الخمس التي يكذب بها اختبار المحاكاة، وانضباط الإزاحة الزمنية (Shift) بالتفصيل، ولماذا يوجّه Titan كل مقياس عبر وحدة واحدة لا وجود فيها أصلاً للنسخة غير الآمنة. ويمكنك أن ترى نسخة مُنقّحة وقابلة للتشغيل من اختبار السببية، والبنية التي يعيش فيها، على GitHub، المرخّص بموجب Apache-2.0. وهذا الفصل جزء من كتاب Building a Production Quant Trading System؛ والكتاب الكامل، نسخة رقمية حيّة على Leanpub ونسخة ورقية مطبوعة على Amazon، يحمل النصف المدفوع في التحجيم وبناء المحفظة وتشغيل النظام فعلياً. وإن كانت هذه الملاحظات الميدانية مفيدة، فالنشرة البريدية هي حيث تصل الملاحظة التالية.
هذا مقال هندسي، وليس نصيحة استثمارية، ولا يحتوي على أي استراتيجية قابلة للتداول. جميع الأرقام توضيحية ومُنقّحة، وقصص المعارك فيه عن أخطاء برمجية، لا عن أرباح.
Chart
One .shift(1) is the whole difference
The same-bar collect, where a position earns the very bar that chose it, manufactures a plausible but fake curve; the .shift(1) version on the identical data is flat. Illustrative and sanitised.
Figure
The corrupt-the-future test
A framework-agnostic causality check: nothing computed at or before T may depend on a price after T.
- 1
Pick a cut date T
somewhere in the middle of the price series
- 2
Compute on CLEAN data, keep the slice up to T
a signal, a position, or the whole feature matrix
- 3
Poison every price strictly AFTER T
multiply by noise, reverse it, or zero it
- 4
Recompute on the POISONED data, keep the slice up to T
- 5
Assert the two pre-T slices are identical
if the past moved, the leak is at the first divergent bar
It does not ask whether the code looks causal. It asks whether the answer changes when you change data that should not be visible yet.
قراءات إضافية
- اختبارك التاريخي ليس دليلاً: لماذا تموت أنظمة التداول الكمي قبل أن تتداول
منحنى الأسهم الجميل هو النتيجة الافتراضية لأي اختبار محاكاة تاريخية، وليس إنجازاً. إليك لماذا تنحاز كل أنماط الفشل في التداول الكمي نحو التفاؤل، والعادة الوحيدة التي تصمد أمام السوق الحقيقي: الشك قبل الاحتفال.
- الشك قبل الاحتفال: قراءة في كتاب "بناء بيئة برمجية للتداول الكمي الإنتاجي"
مقدمة لكتابي "بناء بيئة برمجية للتداول الكمي الإنتاجي"، وهو دليل عملي لنسبة الـ 90% غير البراقة من التداول المنهجي: لماذا كتبته، وماذا تتوقع، وماذا لا تتوقع.
مقالات ذات صلة
نسبة شارب المُعدَّلة: لماذا يكون فائز شبكة معاملاتك تشويشاً في الغالب
أفضل نسبة شارب مُتوقَّعة لمسح معاملات ترتفع كلما اتسعت الشبكة، حتى لو كانت كل استراتيجية فيها عديمة القيمة. يشرح هذا المقال نسبة شارب المُعدَّلة كما يراها ممارس فعلي، مع جدول مرجعي صغير يربط N بسقف التشويش يمكنك تطبيقه على شبكتك الليلة، والقاعدة الواحدة التي قتلت أفخر فائز حققته يوماً في مسح معاملات: N هو الحوض، لا المنصة.
سهم واحد يُرسم مرة واحدة: العمارة التي تُبقي البحث خارج الإنتاج
أخطر شيفرة في أي مستودع تداول هي دفتر الملاحظات الاستكشافي الذي لا يجب أن يقترب أبداً من رأس المال الفعلي. قاعدة بنيوية واحدة، هي أن تسري التبعيات في اتجاه واحد فقط، تُبقيه خارج الإنتاج وتحوّل الاقتران الخفي إلى رقم يمكنك أن تراه بأمر واحد من grep.
اشترِ المحرك، وابنِ فرصتك: الخاصية الوحيدة التي تحسم اختيار محرك التداول الكمي
اختيار محرك للتداول الكمي ليس مقارنة سمات بين NautilusTrader وbacktrader وحلقة أحداث تُكتب منزلياً. الأمر يُحسم بخاصية واحدة، ومعيار تقييم يمكن تطبيقه على أي محرك: هل يُشغّل اختبار المحاكاة لديك الشيفرة نفسها التي يعمل بها نظامك الفعلي؟