← Макалаларга кайтуу
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 соода аналитикасы, рынок талдоолору жана платформа жаңылыктары үчүн биздин жаңылыктар бюллетенине жазылыңыз.

Биз сиздин купуялыгыңызды урматтайбыз. Каалаган убакта жазылымдан чыга аласыз.