Skip to Content
The OptimizerSearch space

Search space

Everything the optimiser searches is a search space: a set of dimensions derived from the input.* declarations in your Pine source. This page is about how that set is built, how its size is calculated, and where the calculation will surprise you.

From Pine to dimensions

A declaration with a positional default becomes a dimension. Its type determines the kind:

PineDimensionHow values are drawn
input.int(a, b, "Name")IntegerA stepped grid from min to max
input.float(a, b, "Name")FloatA stepped grid from min to max
input.bool(…)BooleanTwo values
input.string(…, options)CategoricalThe supplied choices
input.timeframe(…)CategoricalThe supplied timeframes

You can exclude a parameter from the search entirely — it keeps its default in every trial.

Why the positional default matters
fast_len = input.int(9, "Fast EMA Length") // ^^^^^^^^^^^ this first argument is what marks it tunable

The tooling reads the first argument as the value to search around and derives the bounds from it. A declaration that does not follow this shape is not picked up as a dimension, and it fails quietly — the parameter simply never varies.

This is also why the Python sweep tooling has a stricter variant of the same idea, and why it silently skips aligned declarations. Two different code paths, two different matching rules, one shared habit worth keeping.

Axis enumeration

A stepped dimension is materialised as a list of values:

KindEnumeration
Boolean[true, false]
Categoricalthe choices, copied
Integermin, min + step, … while ≤ max, each truncated to an integer
Floatmin, min + step, … while ≤ max + step × 0.5, each rounded to the step’s decimal precision
Why floats become infinite

The choice count for a numeric dimension is floor(span / step) + 1. If that exceeds 10 000, the dimension is reported as infinite rather than enumerated.

This is a guard against materialising an enormous array, and it has a visible consequence: Grid and Brute refuse to start on a space containing such a dimension. A float range with too fine a step is therefore not searchable by exhaustive methods at all — you need TPE, SA, GA or Random.

Sizing the space

estimatedCombinations is the product of every included axis’s choice count.

Once the running product exceeds the feasibility limit of 50 000, it is set to Infinity, which clears the finiteSearchSpace flag and disqualifies the space from exhaustive search.

ThresholdEffect
≤ 5 000 combinationsAuto routes to Brute
5 001 – 50 000Auto routes to Grid
> 50 000Not finite → auto routes to TPE
Any float axis over 10 000 valuesNot finite regardless of count
The feasibility limit is a gate, not a truncation

The 50 000 limit does not silently run the first 50 000 combinations and call it done. It marks the space as infinite, and Grid’s initialize() then throws:

“GridOptimizer: search space is not finite — use TPE/SA/GA/Random instead.”

You get an error, not a partial answer. That is the right behaviour — a truncated exhaustive search that reported itself as exhaustive would be far more dangerous.

Sampling

Randomised draws are uniform over each axis:

  • Boolean — rng() < 0.5
  • Categorical — uniform over the choices
  • Integer — min + floor(rng() × n) × step
  • Float — min + rng() × (max − min), i.e. continuous; step is not used
Two asymmetries worth knowing

1. Integers are not clamped to max. The draw is min + floor(rng() * n) * step with no final clamp. If max is not an exact multiple of step above min, that top value is simply never drawn. Enumeration and sampling therefore do not cover exactly the same set.

2. Floats ignore step when sampled. A float dimension is sampled continuously even when a step is declared. Step granularity is re-imposed afterwards where it matters — in the identity key and when snapping a neighbour — so the two views stay consistent, but the raw draw is continuous.

Conditional parameters

A parameter can be marked conditional — “only meaningful when another parameter has this value”. This is honoured by exactly one algorithm.

The identity key

Algorithms that must recognise a point they have already seen use a canonical key built from the values.

The key walks parameters in declaration order, skipping excluded ones, and encodes each:

KindEncoding
IntegerString(Math.round(v))
FloatString(snapToStep(v, step))
Boolean"1" / "0"
CategoricalString(v ?? "")

The encoded title=value fragments are then sorted and joined with &.

Why sorting matters

Sorting makes the key order-independent, so two candidates that assign the same values to the same parameters collide correctly regardless of the order they were assembled in.

Snapping floats to step granularity before encoding means that two values differing only in the last bits of floating-point representation produce the same key. Without this, a de-duplication set built on raw floats would miss near-duplicates and the search would waste trials on points it had already evaluated.

Neighbourhood

Simulated annealing and the genetic algorithm both need to propose a nearby point, and both use the same neighbourhood function.

It perturbs every included dimension on every step — not one chosen dimension:

KindNeighbour
BooleanFlipped with probability ½, else unchanged
Categorical70 % “perturb” (redrawn up to 8 times until it differs; kept if it cannot), 30 % uniform over all choices
IntegerIf the axis has fewer than 4 values and a coin lands right, a uniform jump; otherwise a Gaussian step in step-space with σ = max(1, nSteps / 6), clamped to the axis
Floatcur + gauss() × σ with σ = max((max − min) / 20, step), clamped to range, then snapped to step

Reproducibility

The search is seeded, and the seed is owned in one place.

The default generator is a mulberry32 implementation; SA additionally accepts a raw function or an object exposing next(), and wraps it so draws are clamped to [0, 0.9999999999] and a Gaussian source is available via Box–Muller with a cached spare.

Next

Last updated on