← Мақалаларға оралу
March 11, 2026
5 мин оқу

Координаталық түсу мен Байес оңтайландыруы: қайсысы жақсы параметрлерді табады

Координаталық түсу мен Байес оңтайландыруы: қайсысы жақсы параметрлерді табады
#algotrading
#backtest
#optimization
#Optuna
#TPE
#Bayesian optimization
#coordinate descent
#hyperparameters

Бұл "Иллюзиясыз бэктесттер" сериясының бесінші мақаласы. Алдыңғы мақалаларда біз шығын-пайда асимметриясын, Монте-Карло бутстрэпін, фандинг мөлшерлемелерінің әсерін және жылдам бэктесттер үшін Parquet кэшін қарастырдық. Енді стратегияның оңтайлы параметрлерін табу процесі туралы сөйлесейік — бұл интуиция жиі сәтсіздікке ұшырайтын міндет.

Сізде 12 параметрі бар стратегия бар. Әр параметр ~9 мән қабылдайды. Сіз шектеулі drawdown кезінде PnL-ды максимизациялайтын комбинацияны табуды қалайсыз. Мұны қалай жасайсыз?

Егер жауабыңыз "барлық комбинацияларды қайталаймын" болса — сізде проблема бар. Егер жауабыңыз "бір уақытта бір параметрді өзгертемін" болса — сізде басқа проблема бар. Бұл мақала әр тәсілдің артында қандай проблемалар жасырынғаны және оларды қалай шешу керектігі туралы.

Толық іздеу неге мүмкін емес

The curse of dimensionality: exponential growth of the search space

Өлшемділік ләнеті

Толық іздеу (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

Екі жүз сексен екі миллиард жүгіру. Тіпті бір бэктест 1 секунд алса да (бұл әлі де оптимистік), толық іздеу мынаны алады:

T=282×1093600×24×3658,950 жылT = \frac{282 \times 10^{9}}{3600 \times 24 \times 365} \approx 8{,}950 \text{ жыл}

Бұл экспоненциалды өсу: әрбір жаңа параметр іздеу кеңістігін 9-ға көбейтеді. 13-ші параметрді қосыңыз — және 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 кэші туралы мақалада біз таймфреймдер мен индикаторларды алдын ала есептеу бір бэктестті ~1 секундқа дейін жылдамдатқанын көрсеттік. Бірақ жүгіру басына 0,1 секунд болса да, 12 параметрдің толық іздеуі 895 жыл талап етеді. Алдын ала есептеу көмектеседі, бірақ экспоненциалды өсудің іргелі проблемасын шешпейді.

Бізге параметр кеңістігін толық іздеуден ақылдырақ зерттейтін әдістер керек.

Координаталық түсу және OAT: жылдам, бірақ соқыр

Parameter space exploration: OAT vs Bayesian optimization

Бір идеяның екі нұсқасы

Екі байланысты тәсіл бар — екеуі де бір уақытта бір параметрді оңтайландырады, бірақ өту санында ерекшеленеді:

OAT (One-at-a-Time) сипап шығу — барлық параметрлер бойынша бір өту. Бірінші параметрдің мәндерін қайталап шығу, ең жақсысын бекіту, екіншісіне өту — және т.б. Бір рет. Жылдам және арзан.

Coordinate Descent — көп өтулі. Соңғы параметрді оңтайландырғаннан кейін бірінші параметрге қайта оралып, оптимум өзгергенін тексеру керек (өйткені контекст өзгерді — басқа параметр мәндері енді басқаша). Раундтар жинақталғанша қайталанады. Қымбатырақ, бірақ дәлірек — әр раунд шешімді жетілдіре алады.

Іс жүзінде бэктесттер үшін OAT жиірек қолданылады: 12 параметр бойынша бір өту — 96 жүгіру. 3-5 раундты Coordinate Descent — 300-500 жүгіру, бұл Optuna-мен салыстыруға болады, бірақ оның артықшылықтарынсыз.

Әрқайсысында ~8 мәні бар 12 параметр үшін:

NOAT=K×N=12×8=96 жүгіруN_{OAT} = K \times N = 12 \times 8 = 96 \text{ жүгіру}

Grid search үшін 282×109282 \times 10^9 мен салыстырыңыз. OAT сызықтық: O(NK)O(N^K) орнына O(KN)O(K \cdot N). Бұл оның негізгі артықшылығы да, негізгі проблемасы да.

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 — белсенді уақыттағы PnL қолдану ұсынылады. Бұл метрика позициядағы уақытты ескереді және әртүрлі сауда жиілігі бар стратегияларды дұрыс салыстыруға мүмкіндік береді.

Соқыр аймақ: параметрлердің өзара әрекеттесуі

OAT әрбір параметрдің әсері аддитивті деп болжайды — яғни, бір параметрдің оптимал мәні басқаларының мәндеріне тәуелді емес. Бұл болжам кейбір параметрлер үшін орындалады, бірақ байланысқандар үшін бұзылады.

Аддитивті мен байланысқан параметрлер

Оңтайландыруға дейін — параметрлерді жіктеу пайдалы:

Аддитивті (тәуелсіз) — біреуінің оптимал мәні екіншісіне тәуелді емес. Оларды бір-бірден арзан оңтайландыруға болады:

  • htf_entry_sell пен htf_entry_buy — бір таймфреймде әртүрлі бағыттар (сату/сатып алу) үшін кіру шектері. Сату шегі шорт сигналдарын сүзеді, сатып алу шегі — лонгтарды. Олар қабаттаспайтын мәмілелер жиынтығында жұмыс істейді.
  • tp_target пен be_trigger — take-profit пен breakeven, егер олар қарама-қайшы шығу шарттарын құрмаса.

Байланысқан (интерактивті) — біреуінің оптимал мәні екіншісіне тәуелді. Бірлескен оңтайландыру қажет:

  • htf_entry_sell пен mtf_entry_sell — әртүрлі таймфреймдердегі бірдей бағыт (сату) үшін шектер. HTF қандай сигналдардың MTF-ке жететінін анықтайды, ал MTF шегі сүзу тиімділігін анықтайды. MTF өзгергенде HTF оптимумы ауысады.
  • ltf_entry_sell, mtf_entry_sell, htf_entry_sell — бір бағыт үшін барлық шектер тізбегі.
  • partial_frac пен tp_target — жартылай жабудың мөлшері TP деңгейіне тәуелді.

Практикалық тәсіл: алдымен аддитивті параметрлерді OAT арқылы арзан оңтайландырыңыз. Содан кейін байланысқан топтарды Optuna арқылы оңтайландырыңыз. Бұл бюджетті азайтады: Optuna-да 12 параметрдің орнына, біз тек 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) комбинациясы +51% PnL береді, бірақ 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 iji \neq j болғанда 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} айқас туындыларды ескермейді.

Байланысқан параметрлер үшін (бірнеше таймфреймдегі бір бағыттың шектері) — өзара әрекеттесулер ереже, ерекшелік емес. Жоғарғы таймфреймдегі кіру шегі қандай сигналдардың ортаңғысына жететінін анықтайды, ал ортаңғысындағы шек төменгісіндегі сүзу тиімділігін анықтайды. Аддитивті параметрлер үшін (әртүрлі бағыттар, тәуелсіз сүзгілер) айқас туындылар нөлге жақын — және OAT жақсы жұмыс істейді.

Байес оңтайландыруы: ақылды іздеу

Bayesian optimization: surrogate model of the objective function

Идея

Соқыр санамалау немесе ашкөз іздеудің орнына, Байес оңтайландыруы мақсат функциясының сурогат моделін құрады және әр қадамда күтілетін жақсарту максималды болатын нүктені таңдайды.

Алгоритм:

  1. Бірнеше кездейсоқ нүкте таңдап, мақсат функциясын бағалау
  2. Сурогат модель құру (бақыланған нүктелерден f(θ)f(\theta)-ны жуықтайды)
  3. Күтілетін жақсарту максималды нүктені табу (acquisition функциясы)
  4. Мақсат функциясын сол нүктеде бағалау
  5. Сурогат модельді жаңарту
  6. 3-5 қадамдарды қайталау

OAT-тан негізгі айырмашылығы: Байес оңтайландыруы барлық параметрлерді бір уақытта қарастырады және параметр кеңістігіндегі диагональды жоталарды зерттей алады.

TPE (Tree-structured Parzen Estimator)

TPE sampler: modeling good and bad parameter distributions

TPE — Optuna-дағы әдепкі сэмплер. f(θ)f(\theta)-ны тікелей моделдеудің орнына, TPE екі таралымды моделдейді:

  • l(θ)l(\theta) — мақсат функциясы yy^* шегінен жақсы болатын параметрлер таралымы
  • g(θ)g(\theta) — мақсат функциясы yy^* шегінен нашар болатын параметрлер таралымы

TPE-нің acquisition функциясы — қатынас:

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 кэшімен бэктесттер үшін, мұнда бір жүгіру ~1 секунд алады, әдетте TPE артық көрінеді: ол модельді жылдамырақ құрады және 500+ итерацияға жақсырақ масштабталады.

Optuna-мен практикалық интеграция

Optuna optimization framework: iterative parameter search

Толық жұмыс істейтін мысал

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)}")

Бэктест басына ~1 секунд кезінде (алдын ала есептелген кэшпен):

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"]

Сэмплерлерді салыстыру

Sampler convergence comparison over iterations

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)
  • Принцип: acquisition функциясы бар Гаусс процесі
  • Күшті жақтары: ең жақсы үлгі алу тиімділігі (жақсы нәтиже үшін аз итерация), белгісіздік бағаларын береді
  • Әлсіз жақтары: итерация санында 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-ге қарағанда кеңістіктің жақсырақ қамтылуы, қайталанғыштық
  • Әлсіз жақтары: нәтижелерге бейімделмейді
  • Қашан қолдану керек: TPE-ге ауысудан бұрын алғашқы 50-100 итерация үшін

Қорытынды кесте

Сэмплер Түрі Өзара әрекеттесу Категориялық Ең жақсы бюджет
TPE Байес Ішінара Иә 100-1000
CmaEs Эволюциялық Иә Жоқ 200-2000
GP Байес Иә Шектеулі 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 соңғы PnL бойынша кездейсоқ іздеуден тұрақты түрде 15-20% жақсы нәтиже көрсетеді. GP жақсы нәтижелерді ертерек табады, бірақ итерация саны көп болғанда есептеу шегіне тап болады.

Көп мақсатты оңтайландыру: PnL мен MaxDD

Pareto front: tradeoff between PnL and maximum drawdown

Неге бір критерий жеткіліксіз

Drawdown шектеулерінсіз PnL-ды максимизациялау — апатқа апаратын жол. Шығын-пайда асимметриясына байланысты, PnL +80% және MaxDD -30% бар стратегия PnL +50% және MaxDD -5% бар стратегияға қарағанда едәуір қауіптірек.

Оңтайландыру мәселесі шын мәнінде көп мақсатты:

maxθ  PnL(θ)мына шартпенMaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{мына шартпен} \quad \text{MaxDD}(\theta) \to \min

Бұл мақсаттар қайшылықты: агрессивті параметрлер PnL мен drawdown-ды екеуін де арттырады. Шешім бір нүкте емес, Парето фронты: бір метриканы жақсартпай, екіншісін нашарлатпай болмайтын шешімдер жиынтығы.

Optuna-дағы NSGA-II / NSGA-III

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}%")

Парето фронтындағы нүктені таңдау

Парето фронты бірнеше шешім береді. Біреуін қалай таңдау керек?

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 — бір нүктелік баға, ал нәтиженің тұрақтылығын бағалау үшін Монте-Карло бутстрэпі қажет.

Мысал: Парето фронтындағы үш стратегия

Стратегия PnL MaxDD MaxLev PnL@MaxLev Сауда уақыты
Стратегия A ~55% ~0.9% ~55x ~3025% ~15%
Стратегия B ~25% ~0.75% ~66x ~1650% ~5%
Стратегия C ~300% ~17% ~3x ~900% ~45%

+300% әсерлі PnL бар стратегия C жоғары drawdown-ға байланысты PnL@MaxLev бойынша ең аз тартымды болып шығады. Стратегия A таза левереджделген табыс бойынша көш бастап тұр, бірақ белсенді уақыттағы PnL-ды ескергенде стратегия B артық болуы мүмкін — бос уақыттың 95%-ы басқа стратегиялармен толтырылуы мүмкін.

Контур графиктері және параметр маңыздылығы

Contour plots: visualizing parameter interactions and plateaus

Ландшафтты визуализациялау

Оңтайландырудан кейін — визуализация. 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 болатын параметрлерді әдепкі мәнінде бекітуге болады — бұл мәселенің өлшемділігін азайтады және оңтайландыруды жылдамдатады. Бірақ абай болыңыз: төмен маңыздылық параметрдің тек басқалармен өзара әрекеттесуде маңызды екенін де білдіруі мүмкін. Контур графиктері арқылы тексеріңіз.

Алдын ала есептелген кэш: неге бэктест басына 1 секунд бәрін өзгертеді

Precomputed Parquet cache: accelerating backtests from hours to seconds

Бір бэктестің жылдамдығы қандай оңтайландыру әдісін ала алатыныңызды анықтайды.

Бэктест уақыты 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 сағат алады. Әлі де шыдамды, бірақ итерациялау (мақсат функциясын өзгерту, қайта іске қосу) қымбат. 1 секундта — 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

Практикалық ұсыныстар

Hybrid approach: combining OAT and Bayesian optimization

OAT-ты қашан қолдану керек

OAT мынадай жағдайларда орынды:

  1. Барлау талдауы. Сіз енді ғана стратегияны зерттей бастап, қандай параметрлер жалпы нәтижеге әсер ететінін түсінгіңіз келеді. 1,5 минутта 96 жүгіру — тамаша бастапқы нүкте.

  2. Аддитивті параметрлер. Қабаттаспайтын мәмілелер жиынтығында жұмыс істейтін параметрлер үшін (сату мен сатып алу бағыттары, әртүрлі құралдар), OAT дұрыс нәтижені жылдамырақ береді.

  3. Өте қымбат бэктест. Егер бір жүгіру 10+ минут алса және жылдамдатылмаса, 96 жүгірумен (16 сағат) OAT 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 (сату/сатып алу — әртүрлі бағыттар, қабаттаспайтын мәмілелерде жұмыс істейді)
    • Байланысқан топ, сату: htf_entry_sell, mtf_entry_sell, ltf_entry_sell (сүзу тізбегі: сату сигналдары үшін HTF -> MTF -> LTF)
    • Байланысқан топ, сатып алу: htf_entry_buy, mtf_entry_buy, ltf_entry_buy
  2. Аддитивтіге OAT. Сату мен сатып алу топтарын тәуелсіз оңтайландырыңыз. Сату параметрлері сатып алу мәмілелеріне әсер етпесе — OAT минуттар ішінде дұрыс нәтиже береді.

  3. Байланысқанға Optuna. Әр топтың ішінде (сату: кіру+шығу 6 параметрі) TPE қолданыңыз. 12-нің орнына 6 параметр — бюджет екі есе азаяды.

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. Precompute Parquet cache (once)
2. Classify parameters: additive vs coupled
3. OAT for additive (~50 runs, ~1 min) → fix
4. Optuna TPE for coupled groups (300 iterations x 2 groups, ~10 min)
5. Optuna NSGA-III for meta-parameters (500 iterations, ~8 min) → Pareto front
6. Contour plots → visualize interactions
7. Monte Carlo bootstrap of best points → confidence intervals
8. Walk-Forward → out-of-sample validation

8-қадам — walk-forward оңтайландыру — артық сәйкестендіруден қорғану үшін өте маңызды. Бұл туралы толығырақ алдағы Walk-Forward мақаласында.

Оңтайландыру тұзақтары

Артық сәйкестендіру (overfitting). Параметрлер неғұрлым көп болса және оңтайландыру неғұрлым дәл болса — стратегияны тарихи деректерге бейімдеу қаупі соғұрлым жоғары. 12 параметрі бар 500 Optuna итерациясы оқыту жиынында тамаша жұмыс істейтін, бірақ жаңа деректерде пайдасыз комбинацияны табады.

Қорғаныс:

  • Деректерді train/test (70/30) етіп бөліңіз
  • Тұрақтылықты бағалау үшін Монте-Карло бутстрэпін қолданыңыз
  • Walk-forward арқылы тексеріңіз
  • Платодағы шешімдерге басымдық беріңіз (бұл туралы толығырақ Плато талдауында)

Көптеген салыстырулар мәселесі. Егер сіз 500 комбинацияны тексерсеңіз, кездейсоқ түрде "жақсы" нәтиже табу ықтималдығы артады. Bonferroni түзетуі немесе FDR (False Discovery Rate) бақылауы көмектеседі, бірақ қарапайым тәсіл — үлгіден тыс тексеру.

Жеткіліксіз бюджет. 12 параметр үшін 50 итерациясы бар TPE тым аз. Алғашқы 20 итерация кездейсоқ (startup), моделдеуге тек 30 қалады. Минималды бюджет: 12 параметр үшін 10×K=12010 \times K = 120 итерация, ұсынылған: 3050×K30\text{--}50 \times K.

Freqtrade: ол өндірістік фреймворкте қалай жұмыс істейді

Freqtrade: automated trading framework with Optuna integration

Freqtrade — танымал алготрейдинг фреймворктерінің бірі — Hyperopt модулі арқылы астыртын Optuna-ны қолданады. Оның тәжірибесі біздің ұсыныстарымызды растайды:

  • Сэмплерлер: TPE (әдепкі), GP, CmaEs, NSGA-II, QMC — барлығы конфигурация арқылы қолжетімді
  • Шығын функциялары: 12 кірістірілген шығын функциясы, соның ішінде ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
  • Көп мақсатты: бірнеше метриканы бір мезгілде оңтайландыру үшін NSGA-II мен NSGA-III қолдауы
  • Пайдаланушы сэмплерлері: кез келген Optuna-мен үйлесімді сэмплерді қосу мүмкіндігі

Freqtrade экожүйесінен негізгі сабақ: кірістірілген шығын функциялары типтік сценарийлерді қамтиды, бірақ шынайы оңтайландыру үшін сізге стратегияңыздың ерекшеліктерін ескеретін пайдаланушы мақсат функциясы қажет — белсенді уақыт, фандинг шығындары, нақты орындалу симуляциясы үшін бейімделгіш drill-down.

Қорытынды

Complete optimization pipeline: from data to validated parameters

Coordinate Descent (OAT) — жылдам әрі интуитивті әдіс. 12 параметр үшін тек 96 жүгіру керек және бір жарым минутта аяқталады. Бірақ ол параметрлердің өзара әрекеттесуіне соқыр — ал мульти-таймфрейм стратегияларында өзара әрекеттесулер дерлік әрдайым бар.

Optuna (TPE, GP, CmaEs) арқылы Байес оңтайландыруы параметр кеңістігін тұтастай зерттейді. Алдын ала есептелген Parquet кэшімен 8 минутта 500 итерация OAT-қа көрінбейтін комбинацияларды табады.

Көп мақсатты оңтайландыру (NSGA-III) "PnL-ды максимизациялау" мәселесін "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)

Дәйексөз

@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

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Нарықтан бір қадам алда болыңыз

AI сауда талдаулары, нарық аналитикасы және платформа жаңалықтары үшін біздің ақпараттық бюллетеньге жазылыңыз.

Біз сіздің жекелігіңізді құрметтейміз. Кез келген уақытта жазылымнан шығуға болады.