"""Declared search space — every tunable parameter in one place (doc 05 §2). A search space is a mapping ``name -> (low, high, step)`` plus a set of integer parameter names. The optimizer turns each entry into an Optuna ``suggest_float`` / ``suggest_int``. Nothing tunable should be hidden in the strategy body. Rules (doc 05 §2): - Ranges encode priors, not hope — bracket where the answer plausibly is. - Step matters — too fine explodes the space, too coarse misses optima. - What is NOT in the space is a decision — list exclusions explicitly. - Timeframe is usually fixed per iteration (searching timeframes overfits). """ from __future__ import annotations from typing import TypedDict class Range(TypedDict): """One parameter range: ``low`` to ``high`` in steps of ``step``.""" low: float high: float step: float # name -> (low, high, step) SearchSpace = dict[str, tuple[float, float, float]] def suggest_params( trial, space: SearchSpace, int_params: set[str] | None = None, ) -> dict[str, float | int]: """Sample every parameter in ``space`` from an Optuna trial. Integer params (listed in ``int_params``) use ``suggest_int``; floats use ``suggest_float`` with ``step``. Returns a plain ``dict`` of sampled values keyed by parameter name. """ int_params = int_params or set() sampled: dict[str, float | int] = {} for name, (low, high, step) in space.items(): if name in int_params: lo = int(round(low)) hi = int(round(high)) st = max(int(round(step)), 1) sampled[name] = trial.suggest_int(name, lo, hi, step=st) else: sampled[name] = trial.suggest_float(name, low, high, step=step) return sampled def validate_space(space: SearchSpace, int_params: set[str] | None = None) -> list[str]: """Return a list of problems with the space (empty = OK). Catches: reversed ranges, zero/negative steps, int params with non-integer bounds, duplicate names. Run this once before launching a study so a malformed space doesn't waste a background run. """ int_params = int_params or set() problems: list[str] = [] for name, (low, high, step) in space.items(): if high < low: problems.append(f"{name}: high < low ({low} > {high})") if step <= 0: problems.append(f"{name}: step <= 0 ({step})") if name in int_params: if int(low) != low or int(high) != high: problems.append(f"{name}: int param with non-integer bound ({low}, {high})") return problems