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

12+ параметр үчүн толук издөө эмне үчүн мүмкүн эмес, координаталык түшүү өз ара аракеттенүүлөрдү кантип өткөрүп жиберет, жана TPE сэмплери менен Optuna 500 итерацияда OAT 96-да таба албаган нерсени кантип табат. Практикалык код мисалдары, сэмплерлерди салыштыруу жана көп максаттуу оптималдаштыруу.

Бул "Иллюзиясыз бэктесттер" сериясынын бешинчи макаласы. Мурунку макалаларда биз чыгым-пайда асимметриясын, Монте-Карло бутстрэбин, фандинг мөлчөрлөрүнүн таасирин жана тезирээк бэктесттер үчүн 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×365≈8,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),
    }

# Real example: 12 parameters, 9 values each, 1 second per backtest
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(K⋅N)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_sellmtf_entry_sellPnL
0.020.01+42%
0.020.02+38%
0.030.02+51%
0.030.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=0∀i\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 i≠ji \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) — максаттуу функция y∗y^* чегинен жакшы болгон параметрлердин бөлүштүрүлүшү
  • g(θ)g(\theta) — максаттуу функция y∗y^* чегинен начар болгон параметрлердин бөлүштүрүлүшү

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

# ─── Assumes data is precomputed (see article on Parquet cache) ───
# htf_pre, mtf_pre, ltf_pre — DataFrames with precomputed indicators

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.
    """
    # ... backtest implementation ...
    pass


def objective(trial: optuna.Trial) -> float:
    """Objective function for Optuna."""
    params = {
        # Entry thresholds — higher timeframe
        "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),

        # Entry thresholds — middle timeframe
        "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),

        # Entry thresholds — lower timeframe
        "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),

        # Exit thresholds
        "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),

        # Additional parameters
        "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)

    # Optuna minimizes, so we invert PnL
    return -result["pnl_at_max_lev"]


# Create a study with TPE sampler
study = optuna.create_study(
    sampler=TPESampler(seed=42),
    study_name="strategy_optimization",
    direction="minimize",
)

# Run optimization: 500 iterations
study.optimize(objective, n_trials=500, show_progress_bar=True)

# Results
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×1s≈8 мүнөтT_{500} = 500 \times 1\text{s} \approx 8 \text{ мүнөт}

Толук издөөнүн 8 950 жылы менен салыштырганда сегиз мүнөт. Жана TPE 500 итерацияда OAT 96-да өткөрүп жиберген айкалыштарды табат, себеби ал параметр мейкиндигин бир учурда бир октун ордуна бир мезгилде изилдейт.

Изилдөөнү сактоо жана улантуу

import optuna

# Save to SQLite — can resume after restart
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
)

# First run: 300 iterations
study.optimize(objective, n_trials=300)

# ... script restart ...

# Continuation: 200 more iterations using previous results
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)

    # Constraint: exit threshold <= entry threshold
    if htf_exit > htf_entry:
        raise optuna.TrialPruned()

    # ... remaining parameters and backtest ...
    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%~90n>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


# NSGA-III — better than NSGA-II with 3+ criteria
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 front
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:
            # Calculate MaxLev (as in the article on asymmetry)
            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,
            })

    # Sort by PnL at maximum leverage
    filtered.sort(key=lambda x: x["pnl_at_max_lev"], reverse=True)
    return filtered

Эскертүү: максималдуу левередждеги PnL-ды эсептегенде, фандинг мөлчөрлөрүн эсепке алуу керек, болбосо теориялык жогорку левередж чыныгы рынокто чыгымга айланат. Мындан тышкары, акыркы PnL — бир чекиттик баа, ал эми натыйжанын туруктуулугун баалоо үчүн Монте-Карло бутстрэп керек.

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

СтратегияPnLMaxDDMaxLevPnL@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

# Contour plot: how a pair of parameters affects the objective function
fig_contour = vis.plot_contour(
    study,
    params=["htf_entry_sell", "mtf_entry_sell"],
)
fig_contour.show()

# Importance: which parameters matter most
fig_importance = vis.plot_param_importances(study)
fig_importance.show()

# Optimization history: how the result improved
fig_history = vis.plot_optimization_history(study)
fig_history.show()

# Parallel coordinate plot: visualizing dependencies
fig_parallel = vis.plot_parallel_coordinate(
    study,
    params=["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell"],
)
fig_parallel.show()

# Slice plot: how each parameter affects the result
fig_slice = vis.plot_slice(study)
fig_slice.show()

Контур графиги: өз ара аракеттенүүлөрдү окуу

Контур графиги параметр жубу үчүн максаттуу функциянын эки өлчөмдүү кесилишин курат. Эгер изосызыктар октордун бирине параллель болсо — параметрлер өз ара аракеттенбейт, жана OAT так ошол эле оптимумду табмак. Эгер изосызыктар диагоналдуу болсо — өз ара аракеттенүү бар, жана OAT аны өткөрүп жиберет.

# Build contour plots for all pairs of key parameters
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")

Эгер контур графиги плато көрсөтсө — максаттуу функция аз өзгөргөн аймак — бул жакшы белги. Плато натыйжанын кичине параметр четтөөлөрүнө туруктуу экенин билдирет. Плато анализи жана анын ашыкча ылайыкташуу менен байланышы жөнүндө көбүрөөк — алдыдагы Плато анализи макаласында.

Параметрдин маанилүүлүгү

# fANOVA-based importance
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 OAT500 TPE2000 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

# Load precomputed cache — once
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

# Backtest uses precomputed data — only threshold application
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 параметр — бюджет эки эсе азаят.

# Step 1: OAT for additive pairs
sell_params = oat_sweep(sell_param_grid, run_backtest, initial_params)

# Step 2: Optuna for the coupled sell group
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)
    # exit parameters for sell are also coupled with entry
    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 итерация, сунушталган: 30–50×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.}
}