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

Өлшемділік ләнеті
Толық іздеу (grid search) әр параметр үшін мәндердің әрбір комбинациясын тексереді. 9 мәні бар екі параметр үшін бұл жүгіру — толығымен орындалатын. Үш үшін: — шыдамды.
Бірақ 12 параметрі бар нақты стратегия үшін:
Екі жүз сексен екі миллиард жүгіру. Тіпті бір бэктест 1 секунд алса да (бұл әлі де оптимистік), толық іздеу мынаны алады:
Бұл экспоненциалды өсу: әрбір жаңа параметр іздеу кеңістігін 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: жылдам, бірақ соқыр

Бір идеяның екі нұсқасы
Екі байланысты тәсіл бар — екеуі де бір уақытта бір параметрді оңтайландырады, бірақ өту санында ерекшеленеді:
OAT (One-at-a-Time) сипап шығу — барлық параметрлер бойынша бір өту. Бірінші параметрдің мәндерін қайталап шығу, ең жақсысын бекіту, екіншісіне өту — және т.б. Бір рет. Жылдам және арзан.
Coordinate Descent — көп өтулі. Соңғы параметрді оңтайландырғаннан кейін бірінші параметрге қайта оралып, оптимум өзгергенін тексеру керек (өйткені контекст өзгерді — басқа параметр мәндері енді басқаша). Раундтар жинақталғанша қайталанады. Қымбатырақ, бірақ дәлірек — әр раунд шешімді жетілдіре алады.
Іс жүзінде бэктесттер үшін OAT жиірек қолданылады: 12 параметр бойынша бір өту — 96 жүгіру. 3-5 раундты Coordinate Descent — 300-500 жүгіру, бұл Optuna-мен салыстыруға болады, бірақ оның артықшылықтарынсыз.
Әрқайсысында ~8 мәні бар 12 параметр үшін:
Grid search үшін мен салыстырыңыз. OAT сызықтық: орнына . Бұл оның негізгі артықшылығы да, негізгі проблемасы да.
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 оларды жіберіп алады.
Мәселені формализациялау
мақсат функциясы (PnL) болсын. OAT мынадай нүктені табады:
Бірақ бұл жаһандық оптимум үшін қажетті, жеткілікті емес шарт. Егер Гессе матрицасында маңызды диагональдан тыс элементтер болса — OAT болғанда айқас туындыларды ескермейді.
Байланысқан параметрлер үшін (бірнеше таймфреймдегі бір бағыттың шектері) — өзара әрекеттесулер ереже, ерекшелік емес. Жоғарғы таймфреймдегі кіру шегі қандай сигналдардың ортаңғысына жететінін анықтайды, ал ортаңғысындағы шек төменгісіндегі сүзу тиімділігін анықтайды. Аддитивті параметрлер үшін (әртүрлі бағыттар, тәуелсіз сүзгілер) айқас туындылар нөлге жақын — және OAT жақсы жұмыс істейді.
Байес оңтайландыруы: ақылды іздеу

Идея
Соқыр санамалау немесе ашкөз іздеудің орнына, Байес оңтайландыруы мақсат функциясының сурогат моделін құрады және әр қадамда күтілетін жақсарту максималды болатын нүктені таңдайды.
Алгоритм:
- Бірнеше кездейсоқ нүкте таңдап, мақсат функциясын бағалау
- Сурогат модель құру (бақыланған нүктелерден -ны жуықтайды)
- Күтілетін жақсарту максималды нүктені табу (acquisition функциясы)
- Мақсат функциясын сол нүктеде бағалау
- Сурогат модельді жаңарту
- 3-5 қадамдарды қайталау
OAT-тан негізгі айырмашылығы: Байес оңтайландыруы барлық параметрлерді бір уақытта қарастырады және параметр кеңістігіндегі диагональды жоталарды зерттей алады.
TPE (Tree-structured Parzen Estimator)

TPE — Optuna-дағы әдепкі сэмплер. -ны тікелей моделдеудің орнына, TPE екі таралымды моделдейді:
- — мақсат функциясы шегінен жақсы болатын параметрлер таралымы
- — мақсат функциясы шегінен нашар болатын параметрлер таралымы
TPE-нің acquisition функциясы — қатынас:
TPE үлкен ("жақсы" ұқсас параметрлер) және кіші ("жаман" ұқсас емес параметрлер) нүктелерді таңдайды.
TPE неге бэктесттер үшін қолайлы:
- Параметрлер арасындағы шартты тәуелділіктерді өңдейді
- Мақсат функциясының үздіксіздігін қажет етпейді
- Орташа бюджеттерде тиімді (100-1000 итерация)
- Категориялық және дискретті параметрлерді қолдайды
Гаусс процесі (GP)
TPE-ге балама — Гаусс процесі. GP -ны көп өлшемді қалыпты процесс ретінде моделдейді және тек мән болжамын ғана емес, сонымен қатар әр нүктедегі белгісіздікті де ұсынады.
мұндағы — орташа мән, — ковариация функциясы (ядро).
GP мынадай жағдайларда жақсы жұмыс істейді:
- параметрлер аз (10-15-ке дейін)
- мақсат функциясы тегіс
- әр жүгіру қымбат (минуттар, сағаттар)
Алдын ала есептелген Parquet кэшімен бэктесттер үшін, мұнда бір жүгіру ~1 секунд алады, әдетте TPE артық көрінеді: ол модельді жылдамырақ құрады және 500+ итерацияға жақсырақ масштабталады.
Optuna-мен практикалық интеграция

Толық жұмыс істейтін мысал
import optuna
from optuna.samplers import TPESampler
import numpy as np
def run_backtest(htf_pre, mtf_pre, ltf_pre, **params) -> dict:
"""
Runs a backtest with given parameters.
Returns a dict with metrics: pnl, max_dd, n_trades, trading_time, sharpe.
Uses precomputed Parquet cache — ~1 second per run.
"""
pass
def objective(trial: optuna.Trial) -> float:
"""Objective function for Optuna."""
params = {
"htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
"htf_entry_buy": trial.suggest_float("htf_entry_buy", 0.0, 0.05, step=0.005),
"mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
"mtf_entry_buy": trial.suggest_float("mtf_entry_buy", 0.0, 0.05, step=0.005),
"ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
"ltf_entry_buy": trial.suggest_float("ltf_entry_buy", 0.0, 0.05, step=0.005),
"htf_exit_sell": trial.suggest_float("htf_exit_sell", 0.0, 0.03, step=0.005),
"htf_exit_buy": trial.suggest_float("htf_exit_buy", 0.0, 0.03, step=0.005),
"mtf_exit_sell": trial.suggest_float("mtf_exit_sell", 0.0, 0.03, step=0.005),
"mtf_exit_buy": trial.suggest_float("mtf_exit_buy", 0.0, 0.03, step=0.005),
"min_hold_bars": trial.suggest_int("min_hold_bars", 1, 20),
"trail_pct": trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
}
result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)
return -result["pnl_at_max_lev"]
study = optuna.create_study(
sampler=TPESampler(seed=42),
study_name="strategy_optimization",
direction="minimize",
)
study.optimize(objective, n_trials=500, show_progress_bar=True)
print(f"Best PnL: {-study.best_value:.2f}%")
print(f"Best params: {study.best_params}")
print(f"Total trials: {len(study.trials)}")
Бэктест басына ~1 секунд кезінде (алдын ала есептелген кэшпен):
Толық іздеудің 8 950 жылымен салыстырғанда сегіз минут. Және TPE 500 итерацияда OAT 96-да жіберіп алатын комбинацияларды табады, себебі ол параметр кеңістігін бір уақытта бір осьтің орнына бір мезгілде зерттейді.
Зерттеуді сақтау және жалғастыру
import optuna
study = optuna.create_study(
storage="sqlite:///optuna_study.db",
study_name="strategy_v2",
sampler=TPESampler(seed=42),
direction="minimize",
load_if_exists=True, # continue if study already exists
)
study.optimize(objective, n_trials=300)
study.optimize(objective, n_trials=200)
Шектеулерді қосу
Барлық параметр комбинациялары жарамды емес. Мысалы, шығу шегі кіру шегінен аспауы керек:
def objective_with_constraints(trial: optuna.Trial) -> float:
htf_entry = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
htf_exit = trial.suggest_float("htf_exit_sell", 0.0, 0.03, step=0.005)
if htf_exit > htf_entry:
raise optuna.TrialPruned()
result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)
return -result["pnl_at_max_lev"]
Сэмплерлерді салыстыру

Optuna бірнеше сэмплерлерді қолдайды. Әрқайсысының өз күшті жақтары бар.
TPESampler (әдепкі)
sampler = optuna.samplers.TPESampler(
n_startup_trials=20, # random trials before modeling begins
seed=42,
)
- Принцип: Tree-structured Parzen Estimator
- Күшті жақтары: аралас параметр түрлері үшін жақсы, 1000+ итерацияға дейін масштабталады
- Әлсіз жақтары: параметрлердің күшті өзара әрекеттесуімен тиімділігі төмен болуы мүмкін
- Қашан қолдану керек: басқасын таңдауға негіз болмаса, әдепкі бойынша
CmaEsSampler
sampler = optuna.samplers.CmaEsSampler(seed=42)
- Принцип: Covariance Matrix Adaptation Evolution Strategy — ковариация матрицасын бейімдейтін эволюциялық алгоритм
- Күшті жақтары: үздіксіз параметрлер арасындағы өзара әрекеттесулерді табуда керемет, корреляцияларды ескереді
- Әлсіз жақтары: категориялық параметрлерді қолдамайды, инициализация үшін көбірек итерация талап етеді
- Қашан қолдану керек: барлық параметрлер үздіксіз болса және күшті өзара әрекеттесулерден күдіктенсеңіз
GPSampler
sampler = optuna.samplers.GPSampler(seed=42)
- Принцип: acquisition функциясы бар Гаусс процесі
- Күшті жақтары: ең жақсы үлгі алу тиімділігі (жақсы нәтиже үшін аз итерация), белгісіздік бағаларын береді
- Әлсіз жақтары: итерация санында — болғанда баяу
- Қашан қолдану керек: бір бэктест қымбат болса (минуттар) және бюджет 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 | кезінде жоғары |
| Random | ~42% | ~270 | Минималды |
| QMC | ~43% | ~200 | Минималды |
TPE мен CmaEs соңғы PnL бойынша кездейсоқ іздеуден тұрақты түрде 15-20% жақсы нәтиже көрсетеді. GP жақсы нәтижелерді ертерек табады, бірақ итерация саны көп болғанда есептеу шегіне тап болады.
Көп мақсатты оңтайландыру: PnL мен MaxDD

Неге бір критерий жеткіліксіз
Drawdown шектеулерінсіз PnL-ды максимизациялау — апатқа апаратын жол. Шығын-пайда асимметриясына байланысты, PnL +80% және MaxDD -30% бар стратегия PnL +50% және MaxDD -5% бар стратегияға қарағанда едәуір қауіптірек.
Оңтайландыру мәселесі шын мәнінде көп мақсатты:
Бұл мақсаттар қайшылықты: агрессивті параметрлер 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%-ы басқа стратегиялармен толтырылуы мүмкін.
Контур графиктері және параметр маңыздылығы

Ландшафтты визуализациялау
Оңтайландырудан кейін — визуализация. 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 секунд бәрін өзгертеді

Бір бэктестің жылдамдығы қандай оңтайландыру әдісін ала алатыныңызды анықтайды.
| Бэктест уақыты | 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
Практикалық ұсыныстар

OAT-ты қашан қолдану керек
OAT мынадай жағдайларда орынды:
-
Барлау талдауы. Сіз енді ғана стратегияны зерттей бастап, қандай параметрлер жалпы нәтижеге әсер ететінін түсінгіңіз келеді. 1,5 минутта 96 жүгіру — тамаша бастапқы нүкте.
-
Аддитивті параметрлер. Қабаттаспайтын мәмілелер жиынтығында жұмыс істейтін параметрлер үшін (сату мен сатып алу бағыттары, әртүрлі құралдар), OAT дұрыс нәтижені жылдамырақ береді.
-
Өте қымбат бэктест. Егер бір жүгіру 10+ минут алса және жылдамдатылмаса, 96 жүгірумен (16 сағат) OAT 500 TPE итерациясынан (3,5 күн) артық көрінеді.
Optuna-ны қашан қолдану керек
Көп жағдайда Optuna артық көрінеді:
-
3-тен көп параметр. Өзара әрекеттесулер іс жүзінде кепілдендірілген — OAT оптимумды жіберіп алады.
-
Мульти-таймфрейм стратегиялары. Әртүрлі таймфреймдердегі шектер дерлік әрдайым өзара байланысты.
-
Соңғы оңтайландыру. Стратегия Монте-Карло бутстрэпінен өткенде және оның тұрақтылығына сенімді болғанда — Optuna ең жақсы параметрлерді табады.
-
Көп мақсатты мәселелер. PnL мен MaxDD мен сауда уақыты — OAT бұл мәселені принципті түрде шеше алмайды.
Гибридтік тәсіл: аддитивтіге OAT + байланысқанға Optuna
OAT мен Optuna арасында таңдау қажет емес — оларды біріктірген жақсы:
-
Параметрлерді жіктеу. Аддитивті (тәуелсіз) және байланысқан (интерактивті) деп бөліңіз. 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
- Аддитивті:
-
Аддитивтіге OAT. Сату мен сатып алу топтарын тәуелсіз оңтайландырыңыз. Сату параметрлері сатып алу мәмілелеріне әсер етпесе — OAT минуттар ішінде дұрыс нәтиже береді.
-
Байланысқанға 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 параметр үшін итерация, ұсынылған: .
Freqtrade: ол өндірістік фреймворкте қалай жұмыс істейді

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

Coordinate Descent (OAT) — жылдам әрі интуитивті әдіс. 12 параметр үшін тек 96 жүгіру керек және бір жарым минутта аяқталады. Бірақ ол параметрлердің өзара әрекеттесуіне соқыр — ал мульти-таймфрейм стратегияларында өзара әрекеттесулер дерлік әрдайым бар.
Optuna (TPE, GP, CmaEs) арқылы Байес оңтайландыруы параметр кеңістігін тұтастай зерттейді. Алдын ала есептелген Parquet кэшімен 8 минутта 500 итерация OAT-қа көрінбейтін комбинацияларды табады.
Көп мақсатты оңтайландыру (NSGA-III) "PnL-ды максимизациялау" мәселесін "PnL мен MaxDD-дың Парето фронтын құру" мәселесіне айналдырады — және әртүрлі тәуекел-табыс ымыраластықтары бар шешімдер жиынтығын ұсынады.
Бірақ оңтайландыру — құбырдың бір бөлігі ғана. Табылған параметрлерді Монте-Карло бутстрэпі арқылы тексеру, фандинг мөлшерлемелеріне түзету, белсенді уақытты ескере отырып қайта есептеу және walk-forward тексеруден өткізу керек. Бұл туралы толығырақ серияның алдағы мақалаларында.
Пайдалы сілтемелер
- Optuna: A Next-generation Hyperparameter Optimization Framework (Akiba et al., 2019)
- Algorithms for Hyper-Parameter Optimization (Bergstra et al., 2011) — the original TPE paper
- Optuna Documentation — Samplers
- Optuna Visualization Module
- Hansen, N. — The CMA Evolution Strategy: A Tutorial
- Deb, K. et al. — NSGA-II: A Fast and Elitist Multiobjective Genetic Algorithm (2002)
- Snoek, J. et al. — Practical Bayesian Optimization of Machine Learning Algorithms (2012)
- Freqtrade Documentation — Hyperopt
- Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12
- 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.}
}
Authors
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.