Files
mymt5opp/06-optimization-and-robustness.md
2026-06-26 18:47:35 +08:00

169 lines
8.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 06 — Optimization (Optuna) & Robustness
How to turn the declared search space into a Bayesian search that finds good parameters fast — and how
to avoid the trap that ruins most optimization: **overfitting to the past.** A great backtest is easy;
a robust one is the job.
---
## 1. Why Optuna (and not a grid)
A parameter grid is exponential: 8 parameters × 10 values each = 100 million combinations. Even at
"seconds per backtest" that's infeasible, and most of it is wasteland.
**Optuna** (<https://optuna.org>, docs <https://optuna.readthedocs.io>) uses a **TPE sampler** (Tree-
structured Parzen Estimator) that *learns* the shape of the objective as it goes and spends trials
where results are promising. You get a strong optimum in **hundreds to a few thousand** trials.
Install: `pip install optuna` (doc 01). Core concepts you'll use:
- **Study** — one optimization. Maximizes (or minimizes) an objective. Persisted to SQLite so it can
be resumed, inspected mid-run, and parallelized.
- **Trial** — one parameter sample + its score. `trial.suggest_float/int(name, low, high, step=…)`
draws each parameter; `trial.set_user_attr(...)` stashes the metrics for later inspection.
- **Sampler** — TPE by default; fine to start.
```python
import optuna
study = optuna.create_study(
direction="maximize",
storage="sqlite:///study.db", # persist → resume, inspect, parallelize
study_name="my_iter",
load_if_exists=True,
)
study.optimize(objective, n_trials=1000, n_jobs=4) # n_jobs = parallel workers
```
---
## 2. The objective function
The objective receives a trial, **samples** parameters from the search space, **runs** the engine, and
returns a **score**. Structure it in four clear steps:
```
def objective(trial):
1. sampled = suggest_params(trial) # from SEARCH_SPACE (doc 05)
2. params = build_params(sampled, frozen_baseline)
3. result = engine.run(params, bars, instrument, deposit)
metrics = compute_metrics(result)
4. score, violations = score_metrics(metrics, sampled)
for k, v in metrics.items(): trial.set_user_attr(k, v)
trial.set_user_attr("violations", violations)
return score
```
### Score design
A good single-objective score balances **reward against risk**, for example:
```
score = Net DD_WEIGHT × EquityDrawdown # reward profit, punish the drawdown that earned it
```
The weight makes the optimizer prefer a slightly smaller, much calmer equity curve over a fragile spike.
(Optuna also supports true multi-objective studies — e.g. maximize Net *and* minimize DD on a Pareto
front — but a weighted single objective is simpler and usually enough to start.)
### Constraints as hard rejections, not soft nudges
Some conditions should **disqualify** a trial outright, not just dock points. Implement them as a large
penalty per violation so violators sort to the bottom:
```
violations = []
if metrics.trades < MIN_TRADES: violations.append("too few trades")
if metrics.profit_factor < MIN_PF: violations.append("PF too low")
if metrics.equity_dd > HARD_DD_CAP: violations.append("DD over cap")
score = base_score PENALTY * len(violations) # PENALTY huge, e.g. 1e6
```
Typical constraints worth enforcing:
- **Minimum trade count** (a hard floor, calibrated to the window — e.g. ≥2530 active trades per
year). Few-trade, high-PF results are almost always overfit luck, not edge.
- **Minimum Profit Factor.**
- **Hard drawdown cap** in account currency (e.g. ≤35% of deposit).
- **A path-independent fragility guard.** For a martingale grid, compute the *worst-case floating
drawdown* if a full series accumulates straight down an N% drop, and reject if it's worse than the
baseline's. This catches "the backtest never hit the bad path, but the configuration is a time bomb"
— the failure mode a pure historical backtest can't see.
---
## 3. Don't pick the top-N by score — pick the top-N **diverse**
Optuna converges: the top 10 trials by score are usually near-clones of one peak. Verifying 3 clones in
MT5 tells you nothing about robustness. Instead, pick **meaningfully different** finalists.
**Greedy diverse selection:**
```
1. filter trials by the constraints (drop violators)
2. sort by score
3. start the selected set with the single best trial
4. repeatedly add the remaining trial with the MAXIMUM parameter-distance to the already-selected set,
lightly weighted by its rank so you still prefer strong scores
5. stop at N (23 is plenty)
```
Parameter distance = average normalized difference across all parameters (normalize each to [0,1]:
numeric by minmax, boolean to 0/1, categorical by index). The result is 23 finalists that are each
*good* but reach the result by *different* parameter regions — if they all survive MT5, you have real
robustness, not one lucky corner.
---
## 4. Robustness layers — proving the result isn't overfit
These are **read-only analyses over a finished result** (the study, the trade list, the equity curve).
They never touch the engine. Run them on finalists *before* spending MT5 time. Treat them as
report-only signals by default; tighten into hard gates as you gain confidence.
| Layer | Question it answers | Method |
|-------|---------------------|--------|
| **Stability region** | Is the winner on a *plateau* of good configs, or a lone spike? | Cluster the study's top trials by parameter distance; prefer a finalist from the center of a dense good region, not an isolated peak. |
| **Neighborhood / sensitivity** | Does a small parameter nudge destroy the edge? | Perturb each lever ±1 step; demand all neighbors stay profitable. A fragile optimum fails this. |
| **Walk-forward (WFE)** | Does the edge hold out-of-sample? | Split into in-sample/out-of-sample windows; with the finalist's *fixed* params (no re-fit), compute `OOS_metric / IS_metric`. Add a **purge gap** between IS and OOS so leakage can't help. |
| **Monte-Carlo permutation** | How lucky was the trade *order*? | Shuffle trade order, drop a fraction, jitter PnL; build a distribution of drawdown/Net. A finalist whose real drawdown sits in the ugly tail is fragile. |
| **Deflated Sharpe (DSR)** | Is the Sharpe real after testing thousands of configs? | Adjust the observed Sharpe for the **number of trials** (multiple-testing). Many trials inflate the best result even under pure noise; DSR estimates the probability the true Sharpe beats a deflated benchmark. |
| **Era split** | Does the edge exist in *both halves* of history? | Run the finalist on era-1 vs era-2 separately; an edge present in only one era is regime-luck. |
| **Cost stress** | Does it survive realistic and adverse costs? | Re-run on `real` / `worst_case` / `best_case` instrument profiles (doc 05). Edge only in `best_case` = no edge. |
> These are standard quant techniques, not exotic. Walk-forward and Monte-Carlo catch the everyday
> overfit; the Deflated Sharpe specifically counters the fact that an exhaustive search *will* surface
> an impressive-looking config from noise. References worth reading: López de Prado on the Deflated
> Sharpe Ratio and the dangers of backtest overfitting.
---
## 5. What qualifies as a "finalist"
Be strict — MT5 time is the expensive resource, and a weak finalist wastes it:
- **From a *completed* search only.** An interrupted/partial Optuna study yields *preliminary*
numbers, not verification candidates. The README status must reflect the real process state, never
"continues" if the process is dead.
- **Passes the hard constraints** (trades, PF, DD cap, fragility guard).
- **Not concentrated in one year.** If a single year carries >~40% of total Net, it's fragile — one
regime is doing all the work.
- **Honest about flat periods.** A gate that sat out a bad year "survived by not trading" — describe it
that way, don't dress it up as robust profit.
- **Survives the robustness layers** you've chosen to enforce.
- **Converge to 23 diverse finalists**, not 56. More finalists is usually indecision, not rigor.
---
## 6. Running searches without melting your machine (operational discipline)
- **Smoke first.** A 3040 trial run on a short period validates the whole script (data load → engine →
scoring → storage) in under a minute. Only then launch the full study.
- **Run heavy studies in the background and poll** the trial count in `study.db`; don't block a shell on
a multi-minute run.
- **One study, many workers** (`n_jobs=N`) beats N competing scripts. Cap heavy concurrency at your
performance-core count.
- **Dedup guard:** never relaunch a study you think timed out without checking it isn't still running —
duplicates thrash the CPU and waste hours.
Next: [`07-mt5-bridge.md`](07-mt5-bridge.md) — verifying the finalists in the real terminal.