← العودة إلى قائمة المقالات
March 11, 2026
5 دقائق للقراءة

النزول الإحداثي مقابل التحسين البايزي: أيهما يجد معاملات أفضل

النزول الإحداثي مقابل التحسين البايزي: أيهما يجد معاملات أفضل
#algotrading
#backtest
#optimization
#Optuna
#TPE
#Bayesian optimization
#coordinate descent
#hyperparameters

هذه هي المقالة الخامسة في سلسلة "اختبارات رجعية بلا أوهام". في المقالات السابقة تناولنا عدم تماثل الخسارة والربح، ومحاكاة مونت كارلو بالتمهيد، وتأثير معدلات التمويل، وذاكرة التخزين المؤقت Parquet للاختبارات الرجعية متعددة الأطر الزمنية. والآن لنتحدث عن عملية إيجاد المعاملات المثلى للاستراتيجية — وهي مهمة يخفق فيها الحدس في أغلب الأحيان.

لديك استراتيجية بـ 12 معاملاً. كل معامل يأخذ حوالي 9 قيم. تريد إيجاد التركيبة التي تعظّم الربح والخسارة (PnL) مع تقييد التراجع. كيف تفعل ذلك؟

إذا كانت إجابتك "أجرب جميع التركيبات" — فلديك مشكلة. وإذا كانت إجابتك "أغيّر معاملاً واحداً في كل مرة" — فلديك مشكلة مختلفة. هذه المقالة عن المشاكل الكامنة وراء كل منهج وكيفية حلها.

لماذا البحث الشامل مستحيل

لعنة الأبعاد: النمو الأسي لفضاء البحث

لعنة الأبعاد

البحث الشامل (grid search) يختبر كل تركيبة من القيم لكل معامل. لمعاملين بـ 9 قيم، هذا 92=819^2 = 81 تشغيلة — ممكن تماماً. لثلاثة: 93=7299^3 = 729 — مقبول.

لكن لاستراتيجية حقيقية بـ 12 معاملاً:

Ngrid=912=282,429,536,481N_{grid} = 9^{12} = 282{,}429{,}536{,}481

مائتان واثنان وثمانون مليار تشغيلة. حتى لو استغرق اختبار رجعي واحد ثانية واحدة (وهو تقدير متفائل بالفعل)، فإن البحث الشامل سيستغرق:

T=282×1093600×24×3658,950 سنةT = \frac{282 \times 10^{9}}{3600 \times 24 \times 365} \approx 8{,}950 \text{ سنة}

هذا نمو أسي: كل معامل جديد يضرب فضاء البحث في 9. أضف المعامل الثالث عشر — وبدلاً من 9,000 سنة تحتاج 80,000.

import math

def grid_search_cost(n_params: int, values_per_param: int, seconds_per_trial: float) -> dict:
    """Estimate the cost of exhaustive search."""
    total_trials = values_per_param ** n_params
    total_seconds = total_trials * seconds_per_trial
    return {
        "total_trials": total_trials,
        "total_hours": total_seconds / 3600,
        "total_years": total_seconds / (3600 * 24 * 365),
    }

cost = grid_search_cost(12, 9, 1.0)
print(f"Trials: {cost['total_trials']:,.0f}")      # 282,429,536,481
print(f"Years:  {cost['total_years']:,.0f}")        # 8,950

حتى مع الحساب المسبق

في مقالة ذاكرة التخزين المؤقت Parquet أوضحنا كيف يسرّع الحساب المسبق للأطر الزمنية والمؤشرات اختباراً رجعياً واحداً إلى حوالي ثانية واحدة. لكن حتى عند 0.1 ثانية لكل تشغيلة، فإن البحث الشامل لـ 12 معاملاً سيستغرق 895 سنة. الحساب المسبق يساعد، لكنه لا يحل المشكلة الجوهرية للنمو الأسي.

نحتاج إلى أساليب تستكشف فضاء المعاملات بشكل أذكى من البحث الشامل.

النزول الإحداثي وOAT: سريع لكن أعمى

استكشاف فضاء المعاملات: OAT مقابل التحسين البايزي

نوعان من نفس الفكرة

هناك منهجان متصلان — كلاهما يحسّن معاملاً واحداً في المرة، لكنهما يختلفان في عدد المرات:

مسح OAT (واحد في المرة) — مرور واحد عبر جميع المعاملات. تكرر قيم المعامل الأول، تثبّت الأفضل، تنتقل للثاني — وهكذا. مرة واحدة فقط. سريع ورخيص.

النزول الإحداثي — مرور متعدد. بعد تحسين المعامل الأخير، تعود للأول وتتحقق مما إذا كان الأمثل قد تغيّر (بما أن السياق تغيّر — قيم المعاملات الأخرى أصبحت مختلفة). تكرر الجولات حتى التقارب. أكثر كلفة، لكن أكثر دقة — كل جولة يمكن أن تحسّن الحل.

عملياً، في الاختبارات الرجعية يُستخدم OAT أكثر: مرور واحد عبر 12 معاملاً — 96 تشغيلة. النزول الإحداثي بـ 3-5 جولات — 300-500 تشغيلة، وهو بالفعل مماثل لـ Optuna، لكن بدون مزاياه.

لـ 12 معاملاً بحوالي 8 قيم لكل منها:

NOAT=K×N=12×8=96 تشغيلةN_{OAT} = K \times N = 12 \times 8 = 96 \text{ تشغيلة}

قارن مع 282×109282 \times 10^9 للبحث الشامل. OAT خطي: O(KN)O(K \cdot N) بدلاً من O(NK)O(N^K). هذه ميزته الرئيسية ومشكلته الرئيسية في آن واحد.

def oat_sweep(
    param_grid: dict[str, list],
    run_backtest_fn,
    initial_params: dict,
    metric: str = "effective_score",
) -> dict:
    """
    OAT sweep: single pass, optimizing one parameter at a time.

    param_grid: {"htf_entry_sell": [0.0, 0.005, ..., 0.05], ...}
    initial_params: starting values for all parameters
    metric: metric to optimize (effective_score recommended —
            PnL per active time extrapolated to a year)
    """
    best_params = initial_params.copy()
    best_score = run_backtest_fn(**best_params)[metric]

    for param_name, values in param_grid.items():
        param_best_val = best_params[param_name]
        param_best_score = best_score

        for val in values:
            candidate = best_params.copy()
            candidate[param_name] = val
            result = run_backtest_fn(**candidate)
            score = result[metric]

            if score > param_best_score:
                param_best_score = score
                param_best_val = val

        best_params[param_name] = param_best_val
        best_score = param_best_score
        print(f"{param_name}: best={param_best_val}, score={param_best_score:.4f}")

    return best_params

أي مقياس تختار للتحسين؟ بدلاً من PnL الخام أو PnL@MaxLev، يُنصح باستخدام effective score — الربح والخسارة لكل وقت نشط مستقرَأً إلى سنة. يأخذ هذا المقياس في الاعتبار الوقت في المركز ويسمح بالمقارنة الصحيحة للاستراتيجيات ذات تكرارات التداول المختلفة.

النقطة العمياء: تفاعلات المعاملات

يفترض OAT أن تأثير كل معامل جمعي — أي أن القيمة المثلى لمعامل واحد لا تعتمد على قيم الآخرين. هذا الافتراض صحيح لبعض المعاملات، لكنه يفشل للمعاملات المترابطة.

المعاملات الجمعية مقابل المترابطة

قبل التحسين — من المفيد تصنيف المعاملات:

جمعية (مستقلة) — القيمة المثلى لأحدها لا تعتمد على الآخر. يمكن تحسينها فردياً بتكلفة منخفضة:

  • htf_entry_sell وhtf_entry_buy — عتبات الدخول لاتجاهين مختلفين (بيع/شراء) على نفس الإطار الزمني. عتبة البيع تصفي إشارات البيع القصير، وعتبة الشراء تصفي الشراء الطويل. تعمل على مجموعات فرعية غير متداخلة من الصفقات.
  • tp_target وbe_trigger — جني الأرباح ونقطة التعادل، إذا لم يخلقا شروط خروج متعارضة.

مترابطة (تفاعلية) — القيمة المثلى لأحدها تعتمد على الآخر. يلزم التحسين المشترك:

  • htf_entry_sell وmtf_entry_sell — عتبات لنفس الاتجاه (بيع) على أطر زمنية مختلفة. HTF يحدد أي إشارات تصل إلى MTF، وعتبة MTF تحدد فعالية التصفية. يتحول أمثل HTF عندما يتغير MTF.
  • ltf_entry_sell، mtf_entry_sell، htf_entry_sell — سلسلة العتبات الكاملة لاتجاه واحد.
  • partial_frac وtp_target — حجم الإغلاق الجزئي يعتمد على مستوى TP.

المنهج العملي: أولاً حسّن المعاملات الجمعية عبر OAT بتكلفة منخفضة. ثم حسّن المجموعات المترابطة عبر Optuna. هذا يقلل الميزانية: بدلاً من 12 معاملاً في Optuna، نرسل فقط 6-8 معاملات مترابطة، بينما الباقي مثبّت بالفعل.

مثال: كيف يفوّت OAT تفاعلاً

لنأخذ عتبتين مترابطتين:

  • htf_entry_sell — عتبة على الإطار الزمني الأعلى (اتجاه البيع)
  • mtf_entry_sell — عتبة على الإطار الزمني الأوسط (اتجاه البيع)

يثبّت OAT mtf_entry_sell = 0.01 (القيمة الابتدائية) ويكرر عبر htf_entry_sell. يجد أفضل قيمة: htf_entry_sell = 0.02. يثبّتها وينتقل للمعامل التالي — ولا يعود أبداً.

إليك ما فاته OAT:

htf_entry_sell mtf_entry_sell PnL
0.02 0.01 +42%
0.02 0.02 +38%
0.03 0.02 +51%
0.03 0.01 +35%

التركيبة (0.03, 0.02) تعطي PnL بنسبة +51%، لكن OAT لن يدرسها أبداً لأنه مع mtf_entry_sell = 0.01 المثبّت، القيمة htf_entry_sell = 0.03 تعطي +35% فقط. وقع OAT في الأمثل المحلي (0.02, 0.01) ولا يستطيع رؤية الأمثل الشامل (0.03, 0.02).

هذه مشكلة كلاسيكية: إذا كان سطح دالة الهدف يحتوي على حواف قطرية (عندما ينزاح أمثل معامل بتغيّر آخر)، فإن OAT يفوّتها.

صياغة المشكلة

ليكن f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) دالة الهدف (PnL). يجد OAT نقطة حيث:

fθi=0i\frac{\partial f}{\partial \theta_i} = 0 \quad \forall i

لكن هذا شرط ضروري، لا كافٍ للأمثل الشامل. إذا كانت مصفوفة هيسيان Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} تحتوي على عناصر خارج القطر ذات دلالة — فإن OAT لا يأخذ في الاعتبار المشتقات المتقاطعة 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} عندما iji \neq j.

للمعاملات المترابطة (عتبات نفس الاتجاه عبر أطر زمنية متعددة) — التفاعلات هي القاعدة لا الاستثناء. عتبة الدخول على الإطار الزمني الأعلى تحدد أي إشارات تصل إلى الأوسط، وعتبة الأوسط تحدد فعالية التصفية على الأدنى. للمعاملات الجمعية (اتجاهات مختلفة، مرشحات مستقلة) المشتقات المتقاطعة قريبة من الصفر — ويعمل OAT جيداً.

التحسين البايزي: بحث ذكي

التحسين البايزي: نموذج بديل لدالة الهدف

الفكرة

بدلاً من التعداد الأعمى أو البحث الجشع، يبني التحسين البايزي نموذجاً بديلاً لدالة الهدف ويختار في كل خطوة النقطة التي يكون فيها التحسن المتوقع أقصى.

الخوارزمية:

  1. اختر عدة نقاط عشوائية، قيّم دالة الهدف
  2. ابنِ نموذجاً بديلاً (يقرّب f(θ)f(\theta) من النقاط المرصودة)
  3. جد النقطة ذات أقصى تحسن متوقع (دالة الاستحواذ)
  4. قيّم دالة الهدف عند تلك النقطة
  5. حدّث النموذج البديل
  6. كرر الخطوات 3-5

الفرق الجوهري عن OAT: التحسين البايزي يأخذ في الاعتبار جميع المعاملات في وقت واحد ويمكنه استكشاف الحواف القطرية في فضاء المعاملات.

TPE (مقدّر Parzen ذو البنية الشجرية)

عيّنة TPE: نمذجة توزيعات المعاملات الجيدة والسيئة

TPE هو العيّنة الافتراضية في Optuna. بدلاً من نمذجة f(θ)f(\theta) مباشرة، ينمذج TPE توزيعين:

  • l(θ)l(\theta) — توزيع المعاملات حيث دالة الهدف أفضل من العتبة yy^*
  • g(θ)g(\theta) — توزيع المعاملات حيث دالة الهدف أسوأ من العتبة yy^*

دالة الاستحواذ في TPE هي النسبة:

EI(θ)l(θ)g(θ)\text{EI}(\theta) \propto \frac{l(\theta)}{g(\theta)}

يختار TPE النقاط حيث l(θ)l(\theta) كبير (معاملات مشابهة لـ"الجيدة") وg(θ)g(\theta) صغير (معاملات غير مشابهة لـ"السيئة").

لماذا TPE مناسب للاختبارات الرجعية:

  • يتعامل مع التبعيات الشرطية بين المعاملات
  • لا يتطلب استمرارية دالة الهدف
  • فعّال مع ميزانيات معتدلة (100-1000 تكرار)
  • يدعم المعاملات الفئوية والمنفصلة

العملية الغاوسية (GP)

بديل لـ TPE — العملية الغاوسية. تنمذج GP الدالة f(θ)f(\theta) كعملية طبيعية متعددة المتغيرات وتوفر ليس فقط تنبؤاً بالقيمة، بل أيضاً عدم اليقين عند كل نقطة.

f(θ)GP(m(θ),  k(θ,θ))f(\theta) \sim \mathcal{GP}\bigl(m(\theta),\; k(\theta, \theta')\bigr)

حيث m(θ)m(\theta) هو المتوسط، وk(θ,θ)k(\theta, \theta') هي دالة التغاير (النواة).

تعمل GP جيداً عندما:

  • يكون عدد المعاملات قليلاً (حتى 10-15)
  • تكون دالة الهدف سلسة
  • تكون كل تشغيلة مكلفة (دقائق، ساعات)

للاختبارات الرجعية مع ذاكرة التخزين المؤقت Parquet المحسوبة مسبقاً، حيث تستغرق التشغيلة الواحدة حوالي ثانية واحدة، يُفضّل TPE عادةً: يبني النموذج أسرع ويتوسع بشكل أفضل لـ 500+ تكرار.

التكامل العملي مع Optuna

إطار التحسين Optuna: بحث تكراري عن المعاملات

مثال عملي كامل

import optuna
from optuna.samplers import TPESampler
import numpy as np


def run_backtest(htf_pre, mtf_pre, ltf_pre, **params) -> dict:
    """
    Runs a backtest with given parameters.
    Returns a dict with metrics: pnl, max_dd, n_trades, trading_time, sharpe.
    Uses precomputed Parquet cache — ~1 second per run.
    """
    pass


def objective(trial: optuna.Trial) -> float:
    """Objective function for Optuna."""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),

        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),

        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),

        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),

        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    return -result["pnl_at_max_lev"]


study = optuna.create_study(
    sampler=TPESampler(seed=42),
    study_name="strategy_optimization",
    direction="minimize",
)

study.optimize(objective, n_trials=500, show_progress_bar=True)

print(f"Best PnL: {-study.best_value:.2f}%")
print(f"Best params: {study.best_params}")
print(f"Total trials: {len(study.trials)}")

عند حوالي ثانية واحدة لكل اختبار رجعي (مع ذاكرة التخزين المؤقت المحسوبة مسبقاً):

T500=500×1s8 دقائقT_{500} = 500 \times 1\text{s} \approx 8 \text{ دقائق}

ثماني دقائق مقابل 8,950 سنة للبحث الشامل. ويجد TPE في 500 تكرار تركيبات يفوّتها OAT في 96، لأنه يستكشف فضاء المعاملات بشكل متزامن وليس محوراً واحداً في المرة.

حفظ واستئناف الدراسة

import optuna

study = optuna.create_study(
    storage="sqlite:///optuna_study.db",
    study_name="strategy_v2",
    sampler=TPESampler(seed=42),
    direction="minimize",
    load_if_exists=True,  # continue if study already exists
)

study.optimize(objective, n_trials=300)


study.optimize(objective, n_trials=200)

إضافة قيود

ليست كل تركيبات المعاملات صالحة. على سبيل المثال، عتبة الخروج يجب ألا تتجاوز عتبة الدخول:

def objective_with_constraints(trial: optuna.Trial) -> float:
    htf_entry = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    htf_exit  = trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005)

    if htf_exit > htf_entry:
        raise optuna.TrialPruned()

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)
    return -result["pnl_at_max_lev"]

مقارنة العيّنات

مقارنة تقارب العيّنات عبر التكرارات

يدعم Optuna عدة عيّنات. لكل منها نقاط قوتها.

TPESampler (الافتراضي)

sampler = optuna.samplers.TPESampler(
    n_startup_trials=20,  # random trials before modeling begins
    seed=42,
)
  • المبدأ: Tree-structured Parzen Estimator
  • نقاط القوة: جيد لأنواع المعاملات المختلطة، يتوسع لـ 1000+ تكرار
  • نقاط الضعف: قد يكون أقل كفاءة مع تفاعلات قوية بين المعاملات
  • متى تستخدمه: افتراضياً، إذا لم يكن هناك سبب لاختيار آخر

CmaEsSampler

sampler = optuna.samplers.CmaEsSampler(seed=42)
  • المبدأ: Covariance Matrix Adaptation Evolution Strategy — خوارزمية تطورية تكيّف مصفوفة التغاير
  • نقاط القوة: ممتاز في إيجاد التفاعلات بين المعاملات المستمرة، يأخذ الارتباطات في الاعتبار
  • نقاط الضعف: لا يدعم المعاملات الفئوية، يتطلب تكرارات أكثر للتهيئة
  • متى تستخدمه: إذا كانت جميع المعاملات مستمرة وتشك في وجود تفاعلات قوية

GPSampler

sampler = optuna.samplers.GPSampler(seed=42)
  • المبدأ: العملية الغاوسية مع دالة استحواذ
  • نقاط القوة: أفضل كفاءة عيّنة (تكرارات أقل لنتيجة جيدة)، يوفر تقديرات عدم اليقين
  • نقاط الضعف: O(n3)O(n^3) في عدد التكرارات — بطيء عندما n>200n > 200
  • متى تستخدمه: إذا كان الاختبار الرجعي الواحد مكلفاً (دقائق) والميزانية محدودة بـ 100-200 تكرار

RandomSampler (خط الأساس)

sampler = optuna.samplers.RandomSampler(seed=42)
  • المبدأ: أخذ عيّنات عشوائية منتظمة
  • نقاط القوة: لا يعلق في الأمثل المحلية، تغطية كاملة للفضاء
  • نقاط الضعف: لا يستخدم النتائج السابقة
  • متى تستخدمه: كخط أساس للمقارنة، أو للتحليل الاستكشافي

QMCSampler

sampler = optuna.samplers.QMCSampler(seed=42)
  • المبدأ: Quasi-Monte Carlo (متتابعات Sobol/Halton) — يملأ الفضاء بشكل أكثر انتظاماً من العيّنة العشوائية
  • نقاط القوة: تغطية أفضل للفضاء من RandomSampler، قابلية إعادة الإنتاج
  • نقاط الضعف: لا يتكيف مع النتائج
  • متى تستخدمه: لأول 50-100 تكرار قبل التحول إلى TPE

جدول ملخص

العيّنة النوع التفاعلات فئوي أفضل ميزانية
TPE Bayesian جزئي نعم 100-1000
CmaEs تطوري نعم لا 200-2000
GP Bayesian نعم محدود 50-200
Random عشوائي لا نعم أي (خط أساس)
QMC شبه عشوائي لا لا 50-500

معيار قياس عملي

import optuna
import time

def benchmark_sampler(sampler, n_trials=300):
    """Compare samplers on the same task."""
    study = optuna.create_study(sampler=sampler, direction="minimize")

    start = time.time()
    study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
    elapsed = time.time() - start

    return {
        "best_value": -study.best_value,
        "elapsed_sec": elapsed,
        "best_trial": study.best_trial.number,
    }

samplers = {
    "TPE":    optuna.samplers.TPESampler(seed=42),
    "CmaEs":  optuna.samplers.CmaEsSampler(seed=42),
    "GP":     optuna.samplers.GPSampler(seed=42),
    "Random": optuna.samplers.RandomSampler(seed=42),
    "QMC":    optuna.samplers.QMCSampler(seed=42),
}

for name, sampler in samplers.items():
    result = benchmark_sampler(sampler, n_trials=300)
    print(f"{name:8s}: best PnL={result['best_value']:.2f}%, "
          f"found at trial #{result['best_trial']}, "
          f"time={result['elapsed_sec']:.1f}s")

نتائج نموذجية لاستراتيجية بـ 12 معاملاً:

العيّنة أفضل PnL عُثر عليه في التكرار حمل العيّنة الإضافي
TPE ~51% ~180 منخفض
CmaEs ~49% ~250 متوسط
GP ~48% ~90 مرتفع عند n>200n > 200
Random ~42% ~270 أدنى
QMC ~43% ~200 أدنى

يتفوق TPE وCmaEs باستمرار على البحث العشوائي بنسبة 15-20% في PnL النهائي. يجد GP نتائج جيدة مبكراً لكنه يصطدم بسقف حسابي مع عدد كبير من التكرارات.

التحسين متعدد الأهداف: PnL مقابل MaxDD

جبهة Pareto: المفاضلة بين PnL والتراجع الأقصى

لماذا معيار واحد لا يكفي

تعظيم PnL بدون قيود على التراجع هو طريق نحو الكارثة. استراتيجية بـ PnL +80% وMaxDD -30% هي، بسبب عدم تماثل الخسارة والربح، أكثر خطورة بكثير من استراتيجية بـ PnL +50% وMaxDD -5%.

مسألة التحسين هي في الواقع متعددة الأهداف:

maxθ  PnL(θ)subject toMaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{subject to} \quad \text{MaxDD}(\theta) \to \min

هذه الأهداف متعارضة: المعاملات العدوانية تزيد كلاً من PnL والتراجع. الحل ليس نقطة واحدة، بل جبهة Pareto: مجموعة من الحلول حيث لا يمكنك تحسين مقياس واحد دون إساءة الآخر.

NSGA-II / NSGA-III في Optuna

import optuna

def multi_objective(trial: optuna.Trial) -> tuple[float, float]:
    """Multi-objective function: (PnL, MaxDD)."""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),
        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),
        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),
        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),
        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    pnl = result["pnl"]          # maximize
    max_dd = result["max_dd"]    # minimize (already a negative number)

    return pnl, max_dd  # Optuna: both directions are set in create_study


study = optuna.create_study(
    directions=["maximize", "minimize"],
    sampler=optuna.samplers.NSGAIIISampler(seed=42),
    study_name="multi_objective_strategy",
)

study.optimize(multi_objective, n_trials=500)

pareto_trials = study.best_trials
print(f"Pareto front: {len(pareto_trials)} solutions")

for t in pareto_trials[:5]:
    print(f"  PnL={t.values[0]:.2f}%, MaxDD={t.values[1]:.2f}%")

اختيار نقطة على جبهة Pareto

تقدم جبهة Pareto حلولاً متعددة. كيف تختار واحداً؟

def select_from_pareto(
    pareto_trials: list,
    max_dd_limit: float = -5.0,
    min_pnl: float = 20.0,
) -> list:
    """
    Filter the Pareto front by constraints.

    max_dd_limit: maximum acceptable drawdown (e.g., -5%)
    min_pnl: minimum acceptable PnL (%)
    """
    filtered = []
    for trial in pareto_trials:
        pnl, max_dd = trial.values
        if max_dd >= max_dd_limit and pnl >= min_pnl:
            max_lev = min(50 / abs(max_dd), 100) if max_dd != 0 else 100
            pnl_at_max_lev = pnl * max_lev
            filtered.append({
                "trial": trial,
                "pnl": pnl,
                "max_dd": max_dd,
                "max_lev": max_lev,
                "pnl_at_max_lev": pnl_at_max_lev,
            })

    filtered.sort(key=lambda x: x["pnl_at_max_lev"], reverse=True)
    return filtered

ملاحظة: عند حساب PnL بأقصى رافعة مالية، يجب مراعاة معدلات التمويل، وإلا فإن الرافعة المالية العالية نظرياً ستتحول إلى خسارة في السوق الحقيقي. بالإضافة إلى ذلك، PnL النهائي هو تقدير نقطة واحدة، ولتقييم استقرار النتيجة تحتاج إلى محاكاة مونت كارلو بالتمهيد.

مثال: ثلاث استراتيجيات على جبهة Pareto

الاستراتيجية PnL MaxDD MaxLev PnL@MaxLev وقت التداول
الاستراتيجية أ ~55% ~0.9% ~55x ~3025% ~15%
الاستراتيجية ب ~25% ~0.75% ~66x ~1650% ~5%
الاستراتيجية ج ~300% ~17% ~3x ~900% ~45%

الاستراتيجية ج ذات PnL المثير للإعجاب بنسبة +300% تتبين أنها الأقل جاذبية بمقياس PnL@MaxLev بسبب التراجع العالي. الاستراتيجية أ تتصدر في صافي العائد بالرافعة، لكن عند مراعاة PnL لكل وقت نشط، قد تكون الاستراتيجية ب أفضل — 95% من الوقت الحر يمكن ملؤه باستراتيجيات أخرى.

رسوم الخطوط الكنتورية وأهمية المعاملات

رسوم الخطوط الكنتورية: تصوير تفاعلات المعاملات والهضاب

تصوير المشهد

بعد التحسين — التصوير. يوفر Optuna أدوات مدمجة:

import optuna.visualization as vis

fig_contour = vis.plot_contour(
    study,
    params=["htf_entry_sell", "mtf_entry_sell"],
)
fig_contour.show()

fig_importance = vis.plot_param_importances(study)
fig_importance.show()

fig_history = vis.plot_optimization_history(study)
fig_history.show()

fig_parallel = vis.plot_parallel_coordinate(
    study,
    params=["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell"],
)
fig_parallel.show()

fig_slice = vis.plot_slice(study)
fig_slice.show()

رسم الخطوط الكنتورية: قراءة التفاعلات

يبني رسم الخطوط الكنتورية مقطعاً ثنائي الأبعاد لدالة الهدف لزوج من المعاملات. إذا كانت خطوط التساوي موازية لأحد المحاور — فالمعاملات لا تتفاعل، وكان OAT سيجد نفس الأمثل. إذا كانت خطوط التساوي قطرية — فهناك تفاعل، وسيفوّته OAT.

key_params = ["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell",
              "htf_entry_buy",  "mtf_entry_buy",  "ltf_entry_buy"]

for i, p1 in enumerate(key_params):
    for p2 in key_params[i+1:]:
        fig = vis.plot_contour(study, params=[p1, p2])
        fig.write_image(f"contour_{p1}_vs_{p2}.png")

إذا أظهر رسم الخطوط الكنتورية هضبة — منطقة تتغير فيها دالة الهدف قليلاً — فهذه علامة جيدة. الهضبة تعني أن النتيجة متينة تجاه الانحرافات الصغيرة في المعاملات. المزيد عن تحليل الهضبة وعلاقته بالإفراط في التخصيص — في المقالة القادمة تحليل الهضبة.

أهمية المعاملات

importance = optuna.importance.get_param_importances(study)
for param, imp in importance.items():
    print(f"{param:20s}: {imp:.4f}")

ناتج نموذجي:

htf_entry_sell      : 0.2841
mtf_entry_sell      : 0.2103
ltf_entry_sell      : 0.1567
trail_pct           : 0.1204
htf_entry_buy       : 0.0892
...

المعاملات ذات الأهمية أقل من 0.01 يمكن تثبيتها عند قيمتها الافتراضية — مما يقلل أبعاد المسألة ويسرّع التحسين. لكن كن حذراً: قد تعني الأهمية المنخفضة أيضاً أن المعامل مهم فقط في تفاعله مع الآخرين. تحقق عبر رسوم الخطوط الكنتورية.

ذاكرة التخزين المؤقت المحسوبة مسبقاً: لماذا ثانية واحدة لكل اختبار رجعي تغيّر كل شيء

ذاكرة التخزين المؤقت Parquet المحسوبة مسبقاً: تسريع الاختبارات الرجعية من ساعات إلى ثوانٍ

سرعة الاختبار الرجعي الواحد تحدد أي طريقة تحسين يمكنك تحمّلها.

وقت الاختبار الرجعي 96 OAT 500 TPE 2000 CmaEs
60 ثانية 1.6 ساعة 8.3 ساعات 33 ساعة
10 ثوانٍ 16 دقيقة 83 دقيقة 5.5 ساعات
1 ثانية 1.5 دقيقة 8 دقائق 33 دقيقة
0.1 ثانية 10 ثوانٍ 50 ثانية 3.3 دقائق

عند 60 ثانية لكل اختبار رجعي، 500 تكرار TPE تستغرق 8 ساعات. مقبول بالفعل، لكن التكرار (تغيير دالة الهدف، إعادة التشغيل) مكلف. عند ثانية واحدة — 8 دقائق، ويمكنك تشغيل عشرات التجارب يومياً.

هذا هو بالضبط السبب في أن الحساب المسبق في ذاكرة التخزين المؤقت Parquet ليس مجرد تسريع، بل توسيع لفضاء الأساليب المتاحة. بدون ذاكرة التخزين المؤقت أنت مقيد بـ OAT أو 100 تكرار GP. مع ذاكرة التخزين المؤقت — يمكنك تحمّل 2000 تكرار CmaEs أو NSGA-III متعدد الأهداف بالكامل.

import pyarrow.parquet as pq
import time

t0 = time.time()
htf_pre = pq.read_table("cache/htf_indicators.parquet").to_pandas()
mtf_pre = pq.read_table("cache/mtf_indicators.parquet").to_pandas()
ltf_pre = pq.read_table("cache/ltf_indicators.parquet").to_pandas()
print(f"Cache loaded in {time.time() - t0:.2f}s")  # ~0.3s

t1 = time.time()
result = run_backtest(htf_pre, mtf_pre, ltf_pre, htf_entry_sell=0.02, ...)
print(f"Backtest in {time.time() - t1:.2f}s")  # ~1.0s

توصيات عملية

المنهج الهجين: الجمع بين OAT والتحسين البايزي

متى تستخدم OAT

يكون OAT مبرراً في الحالات التالية:

  1. التحليل الاستكشافي. بدأت للتو في استكشاف استراتيجية وتريد فهم أي المعاملات تؤثر على النتيجة أصلاً. 96 تشغيلة في 1.5 دقيقة — نقطة انطلاق ممتازة.

  2. المعاملات الجمعية. للمعاملات التي تعمل على مجموعات فرعية غير متداخلة من الصفقات (اتجاه البيع مقابل الشراء، أدوات مختلفة)، سيعطي OAT نتيجة صحيحة أسرع.

  3. اختبار رجعي مكلف جداً. إذا استغرقت التشغيلة الواحدة أكثر من 10 دقائق ولا يمكن تسريعها، فإن OAT بـ 96 تشغيلة (16 ساعة) أفضل من 500 تكرار TPE (3.5 أيام).

متى تستخدم Optuna

Optuna مفضّل في معظم الحالات:

  1. أكثر من 3 معاملات. التفاعلات مضمونة عملياً — وسيفوّت OAT الأمثل.

  2. استراتيجيات متعددة الأطر الزمنية. عتبات الأطر الزمنية المختلفة مترابطة دائماً تقريباً.

  3. التحسين النهائي. عندما تجتاز الاستراتيجية محاكاة مونت كارلو بالتمهيد وأنت واثق من متانتها — سيجد Optuna أفضل المعاملات.

  4. المسائل متعددة الأهداف. PnL مقابل MaxDD مقابل وقت التداول — لا يستطيع OAT حل هذه المسألة من حيث المبدأ.

المنهج الهجين: OAT للجمعية + Optuna للمترابطة

لا يتعين عليك الاختيار بين OAT وOptuna — من الأفضل الجمع بينهما:

  1. صنّف المعاملات. قسّمها إلى جمعية (مستقلة) ومترابطة (تفاعلية). مثال لـ 12 معامل فصل:

    • جمعية: htf_entry_sell <-> htf_entry_buy، mtf_entry_sell <-> mtf_entry_buy، ltf_entry_sell <-> ltf_entry_buy (بيع/شراء — اتجاهات مختلفة، تعمل على صفقات غير متداخلة)
    • مجموعة مترابطة sell: htf_entry_sell، mtf_entry_sell، ltf_entry_sell (سلسلة التصفية: HTF -> MTF -> LTF لإشارات البيع)
    • مجموعة مترابطة buy: htf_entry_buy، mtf_entry_buy، ltf_entry_buy
  2. OAT للجمعية. حسّن مجموعتي البيع والشراء بشكل مستقل. إذا كانت معاملات البيع لا تؤثر على صفقات الشراء — سيعطي OAT نتيجة صحيحة في دقائق.

  3. Optuna للمترابطة. داخل كل مجموعة (sell: 6 معاملات entry+exit) استخدم TPE. 6 معاملات بدلاً من 12 — الميزانية تنخفض إلى النصف.

sell_params = oat_sweep(sell_param_grid, run_backtest, initial_params)

def objective_sell(trial):
    params = sell_params.copy()
    params["htf_entry_sell"] = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    params["mtf_entry_sell"] = trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005)
    params["ltf_entry_sell"] = trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005)
    params["htf_exit_sell"] = trial.suggest_float("htf_exit_sell", 0.0, 0.02, step=0.001)
    params["mtf_exit_sell"] = trial.suggest_float("mtf_exit_sell", 0.0, 0.02, step=0.001)
    params["ltf_exit_sell"] = trial.suggest_float("ltf_exit_sell", 0.0, 0.02, step=0.001)
    return -run_backtest(**params)["effective_score"]

study = optuna.create_study(sampler=optuna.samplers.TPESampler())
study.optimize(objective_sell, n_trials=300)  # 6 parameters → 300 is enough

خط أنابيب التحسين الكامل

1. حساب مسبق لذاكرة التخزين المؤقت Parquet (مرة واحدة)
2. تصنيف المعاملات: جمعية مقابل مترابطة
3. OAT للجمعية (~50 تشغيلة، ~1 دقيقة) → تثبيت
4. Optuna TPE للمجموعات المترابطة (300 تكرار × مجموعتين، ~10 دقائق)
5. Optuna NSGA-III للمعاملات الفوقية (500 تكرار، ~8 دقائق) → جبهة Pareto
6. رسوم الخطوط الكنتورية → تصوير التفاعلات
7. محاكاة مونت كارلو بالتمهيد لأفضل النقاط → فترات الثقة
8. Walk-Forward → التحقق خارج العيّنة

الخطوة 8 — تحسين Walk-Forward — حاسمة للحماية من الإفراط في التخصيص. المزيد عن هذا في المقالة القادمة Walk-Forward.

مزالق التحسين

الإفراط في التخصيص. كلما زادت المعاملات وكان التحسين أكثر دقة — ارتفع خطر تكييف الاستراتيجية على البيانات التاريخية. 500 تكرار Optuna مع 12 معاملاً ستجد تركيبة تعمل بشكل مثالي على مجموعة التدريب، لكنها عديمة الفائدة على بيانات جديدة.

الحماية:

مسألة المقارنات المتعددة. إذا اختبرت 500 تركيبة، تنمو احتمالية إيجاد نتيجة "جيدة" بالصدفة. يساعد تصحيح Bonferroni أو التحكم بـ FDR (معدل الاكتشاف الزائف)، لكن المنهج الأبسط هو التحقق خارج العيّنة.

ميزانية غير كافية. TPE بـ 50 تكراراً لـ 12 معاملاً قليل جداً. أول 20 تكراراً عشوائية (بدء التشغيل)، مما يترك 30 فقط للنمذجة. الحد الأدنى للميزانية: 10×K=12010 \times K = 120 تكراراً لـ 12 معاملاً، والموصى به: 3050×K30\text{--}50 \times K.

Freqtrade: كيف يعمل في إطار عمل إنتاجي

Freqtrade: إطار تداول آلي مع تكامل Optuna

Freqtrade — أحد أطر التداول الخوارزمي الشائعة — يستخدم Optuna تحت الغطاء من خلال وحدة Hyperopt. تجربته تؤكد توصياتنا:

  • العيّنات: TPE (افتراضي)، GP، CmaEs، NSGA-II، QMC — جميعها متاحة عبر الإعدادات
  • دوال الخسارة: 12 دالة خسارة مدمجة، بما فيها ShortTradeDurHyperOptLoss، SharpeHyperOptLoss، MaxDrawDownHyperOptLoss
  • متعدد الأهداف: دعم NSGA-II وNSGA-III للتحسين المتزامن لمقاييس متعددة
  • عيّنات مخصصة: إمكانية توصيل أي عيّنة متوافقة مع Optuna

درس رئيسي من منظومة Freqtrade: دوال الخسارة المدمجة تغطي السيناريوهات النموذجية، لكن للتحسين الجاد تحتاج إلى دالة هدف مخصصة تراعي خصوصيات استراتيجيتك — الوقت النشط، وتكاليف التمويل، والتعمق التكيفي من أجل محاكاة دقيقة للتنفيذ.

الخلاصة

خط أنابيب التحسين الكامل: من البيانات إلى المعاملات المتحقق منها

النزول الإحداثي (OAT) طريقة سريعة وبديهية. لـ 12 معاملاً يتطلب 96 تشغيلة فقط وينتهي في دقيقة ونصف. لكنه أعمى تجاه تفاعلات المعاملات — وفي الاستراتيجيات متعددة الأطر الزمنية، التفاعلات موجودة دائماً تقريباً.

التحسين البايزي عبر Optuna (TPE، GP، CmaEs) يستكشف فضاء المعاملات ككل. 500 تكرار في 8 دقائق — مع ذاكرة التخزين المؤقت Parquet المحسوبة مسبقاً — يجد تركيبات غير مرئية لـ OAT.

التحسين متعدد الأهداف (NSGA-III) يحوّل مسألة "تعظيم PnL" إلى مسألة "بناء جبهة Pareto لـ PnL مقابل MaxDD" — ويوفر مجموعة حلول بمفاضلات مختلفة بين المخاطرة والعائد.

لكن التحسين ليس سوى جزء من خط الأنابيب. المعاملات التي تم إيجادها تحتاج إلى التحقق عبر محاكاة مونت كارلو بالتمهيد، وتصحيحها لـمعدلات التمويل، وإعادة حسابها مع مراعاة الوقت النشط، وتشغيلها عبر تحقق Walk-Forward. المزيد عن ذلك في المقالات القادمة من السلسلة.


روابط مفيدة

  1. Optuna: A Next-generation Hyperparameter Optimization Framework (Akiba et al., 2019)
  2. Algorithms for Hyper-Parameter Optimization (Bergstra et al., 2011) — the original TPE paper
  3. Optuna Documentation — Samplers
  4. Optuna Visualization Module
  5. Hansen, N. — The CMA Evolution Strategy: A Tutorial
  6. Deb, K. et al. — NSGA-II: A Fast and Elitist Multiobjective Genetic Algorithm (2002)
  7. Snoek, J. et al. — Practical Bayesian Optimization of Machine Learning Algorithms (2012)
  8. Freqtrade Documentation — Hyperopt
  9. Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12
  10. Bergstra, J. & Bengio, Y. — Random Search for Hyper-Parameter Optimization (2012)

Citation

@article{soloviov2026optuna,
  author = {Soloviov, Eugen},
  title = {Coordinate Descent vs Bayesian Optimization: Which Finds Better Parameters},
  year = {2026},
  url = {https://marketmaker.cc/en/blog/post/optuna-vs-coordinate-descent},
  description = {Why exhaustive search is impossible for 12+ parameters, how coordinate descent misses interactions, and how Optuna with a TPE sampler finds in 500 iterations what OAT cannot find in 96.}
}
blog.disclaimer

MarketMaker.cc Team

البحوث والاستراتيجيات الكمية

ناقش في تلغرام
Newsletter

ابقَ متقدماً على السوق

اشترك في نشرتنا الإخبارية للحصول على رؤى حصرية حول تداول الذكاء الاصطناعي وتحليلات السوق وتحديثات المنصة.

نحترم خصوصيتك. يمكنك إلغاء الاشتراك في أي وقت.