* fix(quickadapter): validate Optuna numeric configuration
* refactor(quickadapter): reuse shared numeric validator for Optuna config
Route the Optuna numeric configuration validation through a new public
Utils.require_numeric() helper that wraps the canonical _NumericValidator,
instead of reimplementing bool/int/range checks inline. This removes the
duplicated validation logic, harmonizes error messages with the rest of
the module, and drops the unreachable math.isfinite() branch on
space_fraction (non-finite values already fail the range check).
Also align the README seed range notation (int [0,
4294967295]) with the
existing int [2, 10000] style and the emitted error message.
Behavior-preserving: raise/pass outcomes are identical to the previous
implementation across the full option matrix, except unbounded integer
options with values >= 2**64 (n_trials, timeout, n_startup_trials) are now
rejected via the shared finiteness contract (values <= 2**63 unchanged).
* fix(quickadapter): preserve numeric validation contracts
Keep exact built-in type semantics when using the shared helper and treat arbitrary-precision Python integers as finite.
* refactor(quickadapter): reuse shared boolean validator for Optuna config
Route the Optuna boolean option validation through a new public
Utils.require_bool() helper that wraps the canonical _BoolValidator,
mirroring require_numeric(). This removes the last inline validation in
_optuna_config so both boolean and numeric checks now share the framework
validators, closing the residual duplication.
Behavior-preserving: raise/pass outcomes are identical to the previous
inline check across all boolean options (True/False accepted, every other
type rejected).
* docs(quickadapter): justify the int short-circuit in _is_finite_value
The comment stated a Python language truism (integers are arbitrary-precision
and finite) instead of explaining why the branch exists. Reword it to document
the load-bearing rationale: np.isfinite raises TypeError/OverflowError on Python
ints >= 2**64, so routing them through it would misclassify finite values as
non-finite. Comment-only change; no behavior change.
| freqai.optuna_hyperopt.warm_start | true | bool | Warm start HPO with previous best value(s). Persisted values are loaded and saved only in live and dry-run modes; non-live runs reuse only values produced earlier in the same run. |
| freqai.optuna_hyperopt.n_startup_trials | 15 | int >= 0 | HPO startup trials. |
| freqai.optuna_hyperopt.n_trials | 50 | int >= 1 | Maximum HPO trials. |
-| freqai.optuna_hyperopt.n_jobs | 1 | int >= 1 | Parallel HPO workers. The effective value is capped at `max(1, CPU threads // 4)`; that cap is not the default. |
+| freqai.optuna_hyperopt.n_jobs | 1 | int >= 1 | Parallel HPO workers. |
| freqai.optuna_hyperopt.timeout | 7200 | int >= 0 | HPO wall-clock timeout in seconds. |
| freqai.optuna_hyperopt.label_candles_step | 1 | int >= 1 | Step for Zigzag NATR period `label` search space. |
| freqai.optuna_hyperopt.space_reduction | false | bool | Enable/disable `hp` search space reduction based on previous best parameters. |
| freqai.optuna_hyperopt.space_fraction | 0.4 | float [0,1] | Fraction of the `hp` search space to use with `space_reduction`. Lower values create narrower search ranges around the best parameters. |
| freqai.optuna_hyperopt.min_resource | 3 | int >= 1 | Minimum resource per [HyperbandPruner](https://optuna.readthedocs.io/en/stable/reference/generated/optuna.pruners.HyperbandPruner.html) rung. |
-| freqai.optuna_hyperopt.seed | 1 | int >= 0 | HPO RNG seed used by the Optuna samplers and label-candle shuffling. |
+| freqai.optuna_hyperopt.seed | 1 | int [0, 4294967295] | HPO RNG seed used by the Optuna samplers and label-candle shuffling. |
| freqai.optuna_hyperopt.reset_label_study_on_schema_mismatch | true | bool | Reset a persisted `label` study when its selection schema is missing, invalid, or incompatible. `true` keeps the historical destructive reset, deleting the study before recreating it; `false` preserves its trials and stored metadata, permits caller-managed reuse in memory, and does not persist selected params until the schema is reconciled. Both fail closed: an inspection error, or (under `true`) a deletion error, aborts study creation. Has no effect when `continuous=true` or outside live/dry-run modes, where studies are always reset. |
| freqai.optuna_hyperopt.vary_model_seed_by_trial | true | bool | Add `trial.number` to each regressor's configured model seed (or its default seed of `1`) during HPO. `true` samples model randomness across trials and preserves the historical behavior; `false` evaluates every trial and the final fit with the same model seed. This does not change `freqai.optuna_hyperopt.seed`. |
migrate_config,
optuna_load_best_params,
optuna_save_best_params,
+ require_bool,
+ require_numeric,
sanitize_and_renormalize,
safe_distribution_fit,
summarize_label_weight_support,
OPTUNA_SPACE_REDUCTION_DEFAULT: Final[bool] = False
OPTUNA_SPACE_FRACTION_DEFAULT: Final[float] = 0.4
OPTUNA_SEED_DEFAULT: Final[int] = 1
+ _OPTUNA_SEED_MAX: Final[int] = 2**32 - 1
OPTUNA_RESET_LABEL_STUDY_ON_SCHEMA_MISMATCH_DEFAULT: Final[bool] = True
OPTUNA_VARY_MODEL_SEED_BY_TRIAL_DEFAULT: Final[bool] = True
"vary_model_seed_by_trial",
)
+ _OPTUNA_INT_OPTION_BOUNDS: Final[dict[str, tuple[int, Optional[int]]]] = {
+ "n_jobs": (1, None),
+ "n_startup_trials": (0, None),
+ "n_trials": (1, None),
+ "timeout": (0, None),
+ "label_candles_step": (1, None),
+ "min_resource": (1, None),
+ "seed": (0, _OPTUNA_SEED_MAX),
+ }
+
_DATA_SPLIT_METHODS: Final[tuple[str, ...]] = (
"train_test_split",
"timeseries_split",
def _optuna_config(self) -> dict[str, Any]:
optuna_default_config = {
"enabled": False,
- "n_jobs": min(
- self.config.get("freqai", {})
- .get("optuna_hyperopt", {})
- .get("n_jobs", QuickAdapterRegressorV3.OPTUNA_N_JOBS_DEFAULT),
- max(int(self.max_system_threads / 4), 1),
- ),
+ "n_jobs": QuickAdapterRegressorV3.OPTUNA_N_JOBS_DEFAULT,
"sampler": QuickAdapterRegressorV3._OPTUNA_HPO_SAMPLERS.tpe,
"storage": QuickAdapterRegressorV3._STORAGE_FILE,
"continuous": True,
**optuna_hyperopt,
}
for option in QuickAdapterRegressorV3._OPTUNA_BOOL_OPTIONS:
- if not isinstance(optuna_config[option], bool):
- raise ValueError(
- f"freqai.optuna_hyperopt.{option} must be a boolean "
- f"(got {type(optuna_config[option]).__name__})"
- )
+ require_bool(
+ optuna_config[option],
+ option,
+ context="freqai.optuna_hyperopt",
+ )
+ for option, (
+ minimum,
+ maximum,
+ ) in QuickAdapterRegressorV3._OPTUNA_INT_OPTION_BOUNDS.items():
+ require_numeric(
+ optuna_config[option],
+ option,
+ context="freqai.optuna_hyperopt",
+ minimum=minimum,
+ maximum=maximum,
+ require_int=True,
+ )
+ require_numeric(
+ optuna_config["space_fraction"],
+ "space_fraction",
+ context="freqai.optuna_hyperopt",
+ minimum=0,
+ maximum=1,
+ )
return optuna_config
@property
def _is_finite_value(value: Any) -> bool:
- # ``np.isfinite`` raises/returns non-scalar on inputs it cannot reduce to a
- # single finite float64 (Python ints >= 2**64 -> ``TypeError``/``OverflowError``;
- # array-likes -> a ``ValueError`` on ``bool()``); treat all of those as non-finite.
+ # Short-circuit Python ints (always finite): ``np.isfinite`` raises on ints
+ # >= 2**64 (``TypeError``/``OverflowError``), so routing them through it
+ # would misclassify finite values as non-finite. ``np.isfinite`` also
+ # raises/returns non-scalar on array-likes (``ValueError`` on ``bool()``);
+ # treat those remaining failures as non-finite.
+ if isinstance(value, int):
+ return True
try:
return bool(np.isfinite(value))
except (TypeError, OverflowError, ValueError):
return result
+def require_numeric(
+ value: Any,
+ name: str,
+ *,
+ context: str,
+ minimum: float | None = None,
+ maximum: float | None = None,
+ min_exclusive: bool = False,
+ max_exclusive: bool = False,
+ require_int: bool = False,
+) -> int | float:
+ """Return an exact built-in numeric value that satisfies the requested bounds."""
+ validator = _NumericValidator(
+ min_value=minimum,
+ max_value=maximum,
+ min_exclusive=min_exclusive,
+ max_exclusive=max_exclusive,
+ require_int=require_int,
+ )
+ accepted_types = (int,) if require_int else (int, float)
+ if type(value) not in accepted_types or not validator(value):
+ raise ValueError(
+ f"Invalid {context}.{name} value {value!r}: {validator.message(name)}"
+ )
+ return value
+
+
+def require_bool(value: Any, name: str, *, context: str) -> bool:
+ validator = _BoolValidator()
+ if not validator(value):
+ raise ValueError(
+ f"Invalid {context}.{name} value {value!r}: {validator.message(name)}"
+ )
+ return value
+
+
def validate_range(
min_val: float | int,
max_val: float | int,