]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
test(quickadapter): cover the strategy business logic and gate coverage at 67% (...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 1 Oct 2026 11:00:08 +0000 (13:00 +0200)
committerGitHub <noreply@github.com>
Thu, 1 Oct 2026 11:00:08 +0000 (13:00 +0200)
Adds a unit-test suite for the QuickAdapter strategy covering the testable business logic of all five source modules, and an absolute coverage gate enforced in CI.

Branch coverage of quickadapter/user_data goes from 23.9% to 69.2% under the shipped configuration, with an enforced floor of 67%. The before column is main's single test file run against the same config, so both share one denominator; without include_namespace_packages the unexecuted trees vanish from the report entirely and the gate measures only what happened to be imported.

Layout is one file per business concern over a shared qa_support helper. The suite contract derives every production module name from the tree and refuses a second import identity for a strategies module, because a dotted import would shadow the bare one production uses and carry its own copy of the module globals.

Each file was checked against injected mutants of its own source; an independent adversarial review found four survivors the per-commit notes had mislabelled as equivalent or dead, all now covered and verified in both directions.

34 files changed:
.github/workflows/quality.yml
README.md
openspec/config.yaml
quickadapter/.coveragerc [new file with mode: 0644]
quickadapter/.devcontainer/requirements-dev.txt
quickadapter/tests/qa_support.py [new file with mode: 0644]
quickadapter/tests/test_coverage_floor.py [new file with mode: 0644]
quickadapter/tests/test_enum_errors.py [new file with mode: 0644]
quickadapter/tests/test_label_transformer.py [new file with mode: 0644]
quickadapter/tests/test_prediction_history.py
quickadapter/tests/test_regressor_data_split.py [new file with mode: 0644]
quickadapter/tests/test_regressor_label_config.py [new file with mode: 0644]
quickadapter/tests/test_regressor_optuna_study.py [new file with mode: 0644]
quickadapter/tests/test_regressor_prediction_dedup.py [new file with mode: 0644]
quickadapter/tests/test_regressor_prediction_history.py [new file with mode: 0644]
quickadapter/tests/test_regressor_selection.py [new file with mode: 0644]
quickadapter/tests/test_regressor_weight_support.py [new file with mode: 0644]
quickadapter/tests/test_strategy_features.py [new file with mode: 0644]
quickadapter/tests/test_strategy_pure.py [new file with mode: 0644]
quickadapter/tests/test_strategy_reversal.py [new file with mode: 0644]
quickadapter/tests/test_strategy_sizing.py [new file with mode: 0644]
quickadapter/tests/test_strategy_targets.py [new file with mode: 0644]
quickadapter/tests/test_suite_contract.py [new file with mode: 0644]
quickadapter/tests/test_utils_canonical_defaults.py [new file with mode: 0644]
quickadapter/tests/test_utils_config_migration.py [new file with mode: 0644]
quickadapter/tests/test_utils_config_resolution.py [new file with mode: 0644]
quickadapter/tests/test_utils_formatting.py [new file with mode: 0644]
quickadapter/tests/test_utils_indicators.py [new file with mode: 0644]
quickadapter/tests/test_utils_label_weights.py [new file with mode: 0644]
quickadapter/tests/test_utils_numeric_safety.py [new file with mode: 0644]
quickadapter/tests/test_utils_regressor_plumbing.py [new file with mode: 0644]
quickadapter/tests/test_utils_step_math.py [new file with mode: 0644]
quickadapter/tests/test_utils_validation_algebra.py [new file with mode: 0644]
quickadapter/tests/test_utils_zigzag.py [new file with mode: 0644]

index 1f6fdefd8e79eea2cce46e04b36a8c8548551af2..51075a6de10ccf312db7e908340c2b38a0b15d85 100644 (file)
@@ -34,7 +34,7 @@ jobs:
   strategy-qa:
     name: QA (${{ matrix.name }})
     runs-on: ubuntu-latest
-    timeout-minutes: 30
+    timeout-minutes: 45
     strategy:
       fail-fast: false
       matrix:
@@ -43,10 +43,12 @@ jobs:
             context: quickadapter
             project: quickadapter
             image: freqai-strategies-quickadapter-qa:ci
+            coverage: true
           - name: ReforceXY
             context: ReforceXY
             project: reforcexy
             image: freqai-strategies-reforcexy-qa:ci
+            coverage: false
     steps:
       - name: Check out repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -64,6 +66,7 @@ jobs:
             /workspace/scripts/check_basedpyright.py \
             --project "${{ matrix.project }}"
       - name: Run runtime regressions
+        if: ${{ !matrix.coverage }}
         run: |
           docker run --rm \
             --mount "type=bind,src=${GITHUB_WORKSPACE},dst=/workspace,readonly" \
@@ -72,6 +75,26 @@ jobs:
             --entrypoint python \
             "${{ matrix.image }}" \
             -m unittest discover -s "${{ matrix.context }}/tests" -v
+      - name: Run runtime regressions with coverage
+        if: matrix.coverage
+        run: |
+          docker run --rm \
+            --mount "type=bind,src=${GITHUB_WORKSPACE},dst=/workspace,readonly" \
+            --workdir /workspace \
+            --env PYTHONPATH=/workspace/${{ matrix.context }}/user_data/strategies \
+            --env COVERAGE_RCFILE=/workspace/${{ matrix.context }}/.coveragerc \
+            --env COVERAGE_FILE=/tmp/.coverage \
+            --entrypoint sh \
+            "${{ matrix.image }}" \
+            -c '
+              set -eu
+              python -m coverage run -m unittest discover -s "${{ matrix.context }}/tests" -v
+              echo "::group::Coverage"
+              status=0
+              python -m coverage report || status=$?
+              echo "::endgroup::"
+              exit $status
+            '
 
   reward-space-analysis:
     name: Reward space analysis
index b194c3cbb3103658a885a07af07fe4a5b15f47f8..1366f85861ae9d2081843a860f0ca28281445beb 100644 (file)
--- a/README.md
+++ b/README.md
@@ -481,12 +481,58 @@ PYTHONPATH=/workspace/quickadapter/user_data/strategies \
   python -m unittest discover -s quickadapter/tests -v
 ```
 
+Both commands must be run from the repository root, which is what the
+container's `--workdir /workspace` provides. To select one concern, pass a
+pattern that matches the whole `module.Class.method` name; a bare substring
+selects more than you want, and a pattern that matches nothing runs zero tests
+and fails with exit code 5:
+
+```shell
+PYTHONPATH=quickadapter/user_data/strategies \
+  python -m unittest discover -s quickadapter/tests -k 'test_utils_zigzag.*' -v
+```
+
 CI runs type checks and runtime regressions in one QA matrix entry per strategy.
 The shared runtime step sets each strategy's `PYTHONPATH`. QuickAdapter needs
 this for direct `unittest` discovery because its model imports `LabelTransformer`
 and `Utils` by bare names; ReforceXY resolves its imports without it, so the
-setting is optional there. The reward-space analysis suite runs separately
-with `uv`, without a Freqtrade image.
+setting is optional there. QuickAdapter's regressions additionally run under
+`coverage.py`; the reward-space analysis suite runs separately with `uv`,
+without a Freqtrade image.
+
+### Coverage gate
+
+QuickAdapter is the only project with a coverage gate. Its configuration is
+`quickadapter/.coveragerc`, selected explicitly because coverage.py looks for
+`.coveragerc` in the directory it is run from and does not search parents:
+
+```shell
+export PYTHONPATH=quickadapter/user_data/strategies
+export COVERAGE_RCFILE=quickadapter/.coveragerc COVERAGE_FILE=/tmp/.coverage
+
+python -m coverage run -m unittest discover -s quickadapter/tests -v
+python -m coverage report
+```
+
+Export both variables rather than prefixing a single command: an inline
+prefix binds to that command only, so `python -m coverage report` would read
+no configuration and exit 0 while measuring nothing.
+
+Branch coverage is required. Every guard in this codebase is an early return
+or a raise, and statement coverage marks a guard covered the instant its `if`
+is evaluated. `include_namespace_packages` is required for the opposite
+reason: without it coverage skips directories that have no `__init__.py`, so
+the `user_data` trees would be absent from the report and the gate would
+measure whatever happened to be imported.
+
+`fail_under` is an absolute floor, never per-module. To change it, re-measure
+with the shipped configuration already in place — a measurement taken without
+`branch` and `include_namespace_packages` reports a different denominator and
+is not a valid input — then update the value and the `# measured` annotation
+above it in the same commit. Raise the floor only; a drop needs the reason in
+the pull request. `quickadapter/tests/test_coverage_floor.py` refuses a
+placeholder, a missing measurement annotation, a disabled branch trace, a
+disabled namespace walk, and any `omit` of production code.
 
 ### Quality checks
 
@@ -547,9 +593,9 @@ wrapper rejects direct host and wrong-image execution so Freqtrade imports and
 dependency versions remain exact.
 
 The BasedPyright and type-stub versions are pinned in each project's
-`.devcontainer/requirements-dev.txt`. The Freqtrade base images intentionally
-follow their rolling `stable_freqai` and `stable_freqairl` tags, so record the
-resolved image digests when a reproducible audit is required.
+`.devcontainer/requirements-dev.txt`, as is `coverage` in QuickAdapter's. The Freqtrade base images
+intentionally follow their rolling `stable_freqai` and `stable_freqairl` tags,
+so record the resolved image digests when a reproducible audit is required.
 
 ## Common workflows
 
index 959fbebe8438fd008224123c8bba72092e454a98..c8bf2b97545752289ec34c5f6fd100b15b57c67b 100644 (file)
@@ -4,7 +4,8 @@ context: |
   Tech stack: Python 3.11+, FreqAI (Freqtrade), Docker
   ML libraries: XGBoost, LightGBM, CatBoost, NGBoost, scikit-learn, NumPy, pandas
   Reinforcement learning: Stable-Baselines3 (ReforceXY strategy)
-  Testing: pytest
+  Testing: stdlib unittest in the Freqtrade QA image for both strategies;
+    pytest with uv for the ReforceXY reward-space analysis package only
   Domain: Algorithmic crypto trading strategies with machine learning
 
   Key strategies:
diff --git a/quickadapter/.coveragerc b/quickadapter/.coveragerc
new file mode 100644 (file)
index 0000000..2966e87
--- /dev/null
@@ -0,0 +1,12 @@
+[run]
+branch = True
+source =
+    quickadapter/user_data
+relative_files = True
+
+[report]
+show_missing = True
+precision = 1
+include_namespace_packages = True
+# measured 2026-10-01 at 69.2% (23.9% before this suite, same configuration) — raise only, never lower
+fail_under = 67
index 685a876ca802188f28fca843e0080cd348c66710..cdffe81322022d1601119ad71f92aaf01096d2b7 100644 (file)
@@ -3,3 +3,4 @@ scipy-stubs==1.18.1.1
 uv
 ruff
 basedpyright==1.40.1
+coverage==7.16.2
diff --git a/quickadapter/tests/qa_support.py b/quickadapter/tests/qa_support.py
new file mode 100644 (file)
index 0000000..07c37e7
--- /dev/null
@@ -0,0 +1,131 @@
+"""Shared builders and process-state resets for the QuickAdapter test suite."""
+
+import copy
+import importlib
+import unittest
+from collections.abc import Iterator, Sequence
+from contextlib import contextmanager
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import Utils
+from freqtrade.enums import RunMode
+
+PAIR = "BTC/USDT"
+REGRESSOR_MODULE = "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3"
+REPO_ROOT = Path(__file__).resolve().parents[2]
+CONFIG_TEMPLATE = REPO_ROOT / "quickadapter" / "user_data" / "config-template.json"
+COVERAGERC = REPO_ROOT / "quickadapter" / ".coveragerc"
+
+# Never handed out: model_config deep-copies it, because both production __init__s call
+# migrate_config, which mutates the config in place via _set_path/_delete_path.
+_BASE_CONFIG: dict[str, Any] = {
+    "timeframe": "5m",
+    "stake_amount": "unlimited",
+    "exchange": {"pair_whitelist": [PAIR]},
+    "pairlists": [{"method": "StaticPairList"}],
+    # No api_server: QuickAdapterV3.__init__ installs the RPC monkeypatch when it is enabled.
+    "freqai": {
+        "enabled": True,
+        "identifier": "quickadapter-runtime-regression",
+        "continual_learning": True,
+        "train_period_days": 1,
+        "backtest_period_days": 1,
+        "conv_width": 1,
+        "fit_live_predictions_candles": 2,
+        "feature_parameters": {
+            "include_timeframes": ["5m"],
+            "include_corr_pairlist": [],
+            "label_period_candles": 1,
+            "shuffle_after_split": False,
+        },
+        "data_split_parameters": {"test_size": 0, "shuffle": False},
+        "model_training_parameters": {"n_estimators": 2, "n_jobs": 1},
+        "label_prediction": {"method": "none"},
+        # optuna_create_sampler resolves the "auto" sampler for both namespaces, and "auto"
+        # is an outbound fetch from hub.optuna.org. Pin both so no test can reach it.
+        "optuna_hyperopt": {"enabled": True, "sampler": "tpe", "label_sampler": "tpe"},
+    },
+}
+
+
+def _merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
+    merged = copy.deepcopy(base)
+    for key, value in overrides.items():
+        current = merged.get(key)
+        if isinstance(current, dict) and isinstance(value, dict):
+            merged[key] = _merge(current, value)
+        else:
+            merged[key] = copy.deepcopy(value)
+    return merged
+
+
+def model_config(tmp_path: Path | str, **overrides: Any) -> dict[str, Any]:
+    """Return a private Freqtrade config rooted at `tmp_path`, deep-merged with `overrides`."""
+    config = _merge(_BASE_CONFIG, overrides)
+    config["user_data_dir"] = tmp_path
+    config["runmode"] = RunMode.DRY_RUN
+    return config
+
+
+@contextmanager
+def temporary_directory() -> Iterator[Path]:
+    """Yield a temporary directory that is removed on exit. The only call shape."""
+    with TemporaryDirectory() as path:
+        yield Path(path)
+
+
+def ohlcv_frame(
+    closes: Sequence[float] | np.ndarray,
+    volume: float | np.ndarray = 10.0,
+    spread: float | np.ndarray = 0.001,
+) -> pd.DataFrame:
+    """Return an OHLCV frame whose close is `closes`, bracketed by a relative `spread`.
+
+    `closes` stays first-positional and accepts a plain sequence as well as an
+    array; the spread is RELATIVE so the high/low bracket scales with the price
+    instead of vanishing on a large series.
+    """
+    values = np.asarray(closes, dtype=float)
+    count = values.size
+    spreads = np.broadcast_to(np.asarray(spread, dtype=float), (count,))
+    volumes = np.broadcast_to(np.asarray(volume, dtype=float), (count,))
+    return pd.DataFrame(
+        {
+            "open": values,
+            "high": values * (1.0 + spreads),
+            "low": values * (1.0 - spreads),
+            "close": values,
+            "volume": volumes,
+            "date": pd.date_range("2024-01-01", periods=count, freq="5min"),
+        }
+    )
+
+
+# Captured once, after Utils finished its import-time registrations, so a test that
+# registers a fake generator is undone without destroying the real ones. Clearing would
+# look equivalent and silently break every later label generation.
+_LABEL_GENERATORS_AT_IMPORT = dict(Utils._LABEL_GENERATORS)
+_WARNED_CONFIG_DEPRECATIONS_AT_IMPORT = set(Utils._WARNED_CONFIG_DEPRECATIONS)
+_REGRESSOR_MODULE = importlib.import_module(REGRESSOR_MODULE)
+_KNOWN_AT_NONE_LOGGED_AT_IMPORT = set(_REGRESSOR_MODULE._KNOWN_AT_NONE_LOGGED)
+
+
+class QaTestCase(unittest.TestCase):
+    """Base case restoring the three process globals, in setUp and again in addCleanup."""
+
+    def setUp(self):
+        self._restore_process_globals()
+        self.addCleanup(self._restore_process_globals)
+
+    @staticmethod
+    def _restore_process_globals() -> None:
+        Utils._WARNED_CONFIG_DEPRECATIONS.clear()
+        Utils._WARNED_CONFIG_DEPRECATIONS.update(_WARNED_CONFIG_DEPRECATIONS_AT_IMPORT)
+        Utils._LABEL_GENERATORS.clear()
+        Utils._LABEL_GENERATORS.update(_LABEL_GENERATORS_AT_IMPORT)
+        _REGRESSOR_MODULE._KNOWN_AT_NONE_LOGGED.clear()
+        _REGRESSOR_MODULE._KNOWN_AT_NONE_LOGGED.update(_KNOWN_AT_NONE_LOGGED_AT_IMPORT)
diff --git a/quickadapter/tests/test_coverage_floor.py b/quickadapter/tests/test_coverage_floor.py
new file mode 100644 (file)
index 0000000..01d7d24
--- /dev/null
@@ -0,0 +1,90 @@
+"""Guards the coverage gate itself; requires the Freqtrade QA image."""
+
+import configparser
+import unittest
+
+from qa_support import COVERAGERC, QaTestCase
+
+FLOOR = "fail_under"
+PLACES = "precision"
+BRANCH = "branch"
+NAMESPACE_PACKAGES = "include_namespace_packages"
+OMIT = "omit"
+
+
+class CoverageFloorTest(QaTestCase):
+    def setUp(self):
+        super().setUp()
+        self.text = COVERAGERC.read_text()
+        self.parser = configparser.ConfigParser()
+        self.parser.read_string(self.text)
+
+    def _raw_floor(self) -> str:
+        """Extract fail_under by whole-line partitioning, not by regex.
+
+        A regex such as ^fail_under\\s*=\\s*(\\S+) pulls the leading digits out of
+        `fail_under = 10        # measured ...` and passes, so it cannot see the
+        malformation that actually breaks the run. Whole-line partitioning yields the
+        comment too, and float() then rejects it, which is the behaviour we want.
+        """
+        for line in self.text.splitlines():
+            key, separator, value = line.partition("=")
+            if separator and key.strip() == FLOOR:
+                return value.strip()
+        raise AssertionError(f"{FLOOR} is absent from {COVERAGERC}")
+
+    def test_the_placeholder_is_substituted(self):
+        self.assertNotIn("__FLOOR__", self.text, "the coverage floor is still a placeholder")
+
+    def test_the_floor_is_a_percentage_in_range(self):
+        value = float(self._raw_floor())
+        self.assertGreater(value, 0.0)
+        self.assertLessEqual(value, 100.0)
+
+    def test_the_floor_carries_its_provenance(self):
+        # The measured date is what tells a reader whether the number is current, and
+        # it is the only thing distinguishing a real floor from a provisional one.
+        lines = [line.strip() for line in self.text.splitlines()]
+        index = next(i for i, line in enumerate(lines) if line.startswith(f"{FLOOR} ="))
+        annotation = " ".join(lines[max(0, index - 2) : index])
+        self.assertIn("measured", annotation, f"the line above {FLOOR} must record the measurement")
+        self.assertNotIn("PROVISIONAL", annotation)
+
+    def test_branch_tracing_is_enabled(self):
+        # Without it, every guard — an early return, a raise — counts as covered the
+        # moment its `if` is evaluated, and the gate measures nothing useful.
+        self.assertTrue(self.parser.getboolean("run", BRANCH))
+
+    def test_unexecuted_files_stay_in_the_denominator(self):
+        self.assertTrue(self.parser.getboolean("report", NAMESPACE_PACKAGES))
+
+    def test_precision_is_set_so_the_floor_is_interpreted_consistently(self):
+        self.assertIn(PLACES, self.parser["report"])
+
+    def test_the_floor_is_bound_in_the_section_coverage_reads_it_from(self):
+        # coverage binds fail_under as `report:fail_under` ONLY. The same key under
+        # [run] is silently inert, which would leave the gate dead while the value, the
+        # provenance and the placeholder checks all still passed, because they read the
+        # file as text. Executed: moving the line (and its comment) into [run] with the
+        # value raised to 99 kept all eight tests green while the CI step exited 0 at
+        # 69.2%. Section membership is therefore asserted, not inferred.
+        self.assertIn(FLOOR, self.parser["report"])
+        self.assertNotIn(FLOOR, self.parser["run"])
+
+    def test_no_production_code_is_omitted(self):
+        # An omit entry would shrink the denominator while the floor stayed put, so the
+        # gate would report the same number over a smaller measurement. Both sections are
+        # checked: coverage accepts omit as run:omit as well as report:omit.
+        for section in ("run", "report"):
+            with self.subTest(section=section):
+                self.assertNotIn(OMIT, self.parser[section])
+
+    def test_the_source_is_the_measured_tree(self):
+        self.assertEqual(
+            [line.strip() for line in self.parser["run"]["source"].splitlines() if line.strip()],
+            ["quickadapter/user_data"],
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_enum_errors.py b/quickadapter/tests/test_enum_errors.py
new file mode 100644 (file)
index 0000000..31bb7ba
--- /dev/null
@@ -0,0 +1,43 @@
+"""Canonical enum error message contract; requires the Freqtrade QA image."""
+
+import unittest
+
+from EnumErrors import enum_error_message
+from qa_support import QaTestCase
+
+
+class EnumErrorMessageTest(QaTestCase):
+    def test_the_message_carries_the_context_the_value_and_the_options(self):
+        self.assertEqual(
+            enum_error_message("data_split_parameters.shuffle", True, ("true", "false")),
+            "Invalid data_split_parameters.shuffle value True: supported values are true, false",
+        )
+
+    def test_the_value_is_rendered_with_its_repr(self):
+        # repr, not str: a boolean must read as True rather than a bare 1, and a string
+        # must keep its quotes so an empty value is still visible.
+        self.assertIn("value True:", enum_error_message("x", True, ("a",)))
+        self.assertIn("value '':", enum_error_message("x", "", ("a",)))
+        self.assertIn("value None:", enum_error_message("x", None, ("a",)))
+
+    def test_options_are_joined_in_the_given_order(self):
+        self.assertTrue(
+            enum_error_message("x", 1, ("first", "second")).endswith(
+                "supported values are first, second"
+            )
+        )
+
+    def test_a_single_option_is_not_pluralised_awkwardly(self):
+        self.assertTrue(enum_error_message("x", 1, ("only",)).endswith("supported values are only"))
+
+    def test_no_options_yields_an_empty_list(self):
+        self.assertTrue(enum_error_message("x", 1, ()).endswith("supported values are "))
+
+    def test_the_same_inputs_always_produce_the_same_message(self):
+        first = enum_error_message("ctx", "v", ("a", "b"))
+        second = enum_error_message("ctx", "v", ("a", "b"))
+        self.assertEqual(first, second)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_label_transformer.py b/quickadapter/tests/test_label_transformer.py
new file mode 100644 (file)
index 0000000..e9f0ddf
--- /dev/null
@@ -0,0 +1,349 @@
+"""Label scaling pipeline contract: round trip, non-finite handling and fit-time refusal."""
+
+import unittest
+
+import numpy as np
+import pandas as pd
+from LabelTransformer import (
+    DEFAULTS_LABEL_PIPELINE,
+    NORMALIZATION_TYPES,
+    STANDARDIZATION_TYPES,
+    LabelTransformer,
+    enum_error_message,
+)
+from qa_support import QaTestCase
+
+LABEL_ARRAY_RTOL = 1e-9
+LABEL_ARRAY_ATOL = 1e-9
+ROUND_TRIP_RTOL = 1e-6
+ROUND_TRIP_ATOL = 1e-9
+
+
+def label_matrix() -> np.ndarray:
+    """Multi-column label matrix: both signs, non-constant in every column, |w| <= 5.
+
+    The magnitude bound is load-bearing: ``sigmoid`` saturates towards the closed
+    interval, so a wide column loses the information its inverse needs. Inside
+    +/-5 every standardization/normalization pair stays inside the invertible region.
+    """
+    return np.array(
+        [
+            [0.0, -1.0, 2.0],
+            [1.0, -0.25, -3.0],
+            [-2.0, 0.5, 4.0],
+            [0.75, 2.0, -0.5],
+            [-0.5, -3.0, 1.5],
+            [2.5, 1.25, 0.0],
+            [-1.5, -1.0, 3.0],
+            [0.125, 0.75, -4.0],
+            [-0.25, 3.0, 0.25],
+            [1.5, -2.5, -1.0],
+        ]
+    )
+
+
+def label_series() -> np.ndarray:
+    """Single label vector, one element per distinct power of two scale."""
+    return np.array([0.0, 1.0, -1.0, 2.0, -2.0, 0.25, -0.25, 4.0, -4.0, 0.5, -0.5])
+
+
+def mixed_finite_matrix() -> np.ndarray:
+    """Label matrix carrying NaN, +inf and -inf at known positions in every column."""
+    return np.array(
+        [
+            [1.0, np.nan, 0.5],
+            [2.0, 3.0, -0.5],
+            [np.inf, 1.5, 0.25],
+            [-3.0, -np.inf, 2.0],
+            [4.0, 0.75, -1.5],
+            [-np.inf, 2.5, 0.125],
+        ]
+    )
+
+
+def transformer(standardization: str = "none", normalization: str = "none", **overrides):
+    """Build a LabelTransformer from pipeline keys, leaving the rest at canonical defaults."""
+    return LabelTransformer(
+        label_transformer={
+            "default": {
+                "standardization": standardization,
+                "normalization": normalization,
+                **overrides,
+            }
+        }
+    )
+
+
+class LabelTransformerTest(QaTestCase):
+    def test_inverse_transform_recovers_the_input_for_every_scaler_pair(self):
+        matrix = label_matrix()
+        for standardization in STANDARDIZATION_TYPES:
+            for normalization in NORMALIZATION_TYPES:
+                with self.subTest(standardization=standardization, normalization=normalization):
+                    fitted = transformer(standardization, normalization)
+                    fitted.fit(matrix)
+                    scaled, *_ = fitted.transform(matrix)
+                    recovered, *_ = fitted.inverse_transform(scaled)
+                    np.testing.assert_allclose(
+                        recovered, matrix, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL
+                    )
+
+    def test_a_non_finite_entry_keeps_its_position_and_kind_through_both_directions(self):
+        matrix = mixed_finite_matrix()
+        finite = np.isfinite(matrix)
+        for standardization in STANDARDIZATION_TYPES:
+            for normalization in NORMALIZATION_TYPES:
+                with self.subTest(standardization=standardization, normalization=normalization):
+                    fitted = transformer(standardization, normalization, gamma=0.5)
+                    fitted.fit(matrix)
+                    scaled, *_ = fitted.transform(matrix)
+                    recovered, *_ = fitted.inverse_transform(scaled)
+                    for result in (scaled, recovered):
+                        np.testing.assert_array_equal(np.isnan(result), np.isnan(matrix))
+                        np.testing.assert_array_equal(np.isposinf(result), np.isposinf(matrix))
+                        np.testing.assert_array_equal(np.isneginf(result), np.isneginf(matrix))
+                        self.assertTrue(np.all(np.isfinite(result[finite])))
+
+    def test_the_finite_entries_are_still_scaled_next_to_a_dropped_one(self):
+        matrix = mixed_finite_matrix()
+        fitted = transformer("none", "maxabs")
+        fitted.fit(matrix)
+        scaled, *_ = fitted.transform(matrix)
+        finite = np.isfinite(matrix)
+        self.assertEqual(float(np.max(np.abs(scaled[finite]))), 1.0)
+        self.assertGreater(float(np.max(np.abs(scaled[finite] - matrix[finite]))), 0.0)
+
+    def test_an_all_non_finite_column_is_fitted_on_the_fallback_and_left_untouched(self):
+        matrix = np.array([[1.0, np.nan], [2.0, np.inf], [3.0, np.nan], [4.0, -np.inf]])
+        fitted = transformer("zscore", "minmax")
+        with self.assertLogs("LabelTransformer", level="WARNING") as captured:
+            fitted.fit(matrix)
+            scaled, *_ = fitted.transform(matrix)
+        self.assertTrue(any("No finite values found" in line for line in captured.output))
+        np.testing.assert_array_equal(np.isnan(scaled[:, 1]), [True, False, True, False])
+        np.testing.assert_array_equal(np.isposinf(scaled[:, 1]), [False, True, False, False])
+        np.testing.assert_array_equal(np.isneginf(scaled[:, 1]), [False, False, False, True])
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_array_equal(np.isnan(recovered[:, 1]), [True, False, True, False])
+        np.testing.assert_array_equal(np.isneginf(recovered[:, 1]), [False, False, False, True])
+
+    def test_transform_before_fit_names_the_forward_verb(self):
+        fitted = transformer()
+        with self.assertRaises(RuntimeError) as caught:
+            fitted.transform(label_matrix())
+        self.assertIn("must be fitted before transform", str(caught.exception))
+
+    def test_inverse_transform_before_fit_names_the_inverse_verb(self):
+        fitted = transformer()
+        with self.assertRaises(RuntimeError) as caught:
+            fitted.inverse_transform(label_matrix())
+        self.assertIn("must be fitted before inverse_transform", str(caught.exception))
+
+    def test_fit_transform_marks_the_transformer_as_fitted(self):
+        matrix = label_matrix()
+        fitted = transformer("zscore", "minmax")
+        scaled, *_ = fitted.fit_transform(matrix)
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_allclose(recovered, matrix, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL)
+
+    def test_an_unseen_column_name_is_refused(self):
+        matrix = label_matrix()
+        fitted = transformer()
+        fitted.fit(matrix, feature_list=["a", "b", "c"])
+        with self.assertRaises(ValueError) as caught:
+            fitted.transform(matrix, feature_list=["a", "b", "renamed"])
+        self.assertEqual(str(caught.exception), "Column 'renamed' was not present during fitting")
+
+    def test_a_column_count_mismatch_names_both_counts(self):
+        fitted = transformer()
+        fitted.fit(label_matrix(), feature_list=["a", "b", "c"])
+        for n_columns in (1, 5):
+            with self.subTest(n_columns=n_columns):
+                with self.assertRaises(ValueError) as caught:
+                    fitted.transform(np.zeros((4, n_columns)))
+                self.assertEqual(
+                    str(caught.exception),
+                    f"Column count mismatch: fitted on 3 columns, got {n_columns}",
+                )
+
+    def test_a_one_dimensional_input_comes_back_one_dimensional(self):
+        series = label_series()
+        for standardization in STANDARDIZATION_TYPES:
+            with self.subTest(standardization=standardization):
+                fitted = transformer(standardization, "minmax")
+                fitted.fit(series)
+                scaled, *_ = fitted.transform(series)
+                recovered, *_ = fitted.inverse_transform(scaled)
+                self.assertEqual(scaled.ndim, 1)
+                self.assertEqual(recovered.ndim, 1)
+                np.testing.assert_allclose(
+                    recovered, series, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL
+                )
+
+    def test_the_column_vector_form_comes_back_two_dimensional(self):
+        series = label_series()
+        fitted = transformer("zscore", "minmax")
+        fitted.fit(series)
+        scaled_1d, *_ = fitted.transform(series)
+        scaled_2d, *_ = fitted.transform(series.reshape(-1, 1))
+        self.assertEqual(scaled_2d.ndim, 2)
+        np.testing.assert_array_equal(scaled_2d.ravel(), scaled_1d)
+
+    def test_the_feature_list_names_the_columns_it_matches(self):
+        matrix = label_matrix()
+        fitted = LabelTransformer(
+            label_transformer={
+                "default": {"standardization": "none", "normalization": "none"},
+                "columns": {"b": {"standardization": "zscore"}},
+            }
+        )
+        fitted.fit(matrix, feature_list=["a", "b", "c"])
+        scaled, *_ = fitted.transform(matrix)
+        np.testing.assert_allclose(scaled[:, 0], matrix[:, 0], rtol=0.0, atol=0.0)
+        self.assertLess(float(np.max(np.abs(scaled[:, 1]))), np.max(np.abs(matrix[:, 1])))
+        np.testing.assert_allclose(scaled[:, 2], matrix[:, 2], rtol=0.0, atol=0.0)
+
+    def test_a_feature_list_of_the_wrong_length_leaves_the_columns_positionally_named(self):
+        matrix = label_matrix()
+        fitted = LabelTransformer(
+            label_transformer={
+                "default": {"standardization": "none", "normalization": "none"},
+                "columns": {"column_1": {"standardization": "zscore"}},
+            }
+        )
+        fitted.fit(matrix, feature_list=["only_one"])
+        scaled, *_ = fitted.transform(matrix)
+        np.testing.assert_allclose(scaled[:, 0], matrix[:, 0], rtol=0.0, atol=0.0)
+        self.assertLess(float(np.max(np.abs(scaled[:, 1]))), np.max(np.abs(matrix[:, 1])))
+        np.testing.assert_allclose(scaled[:, 2], matrix[:, 2], rtol=0.0, atol=0.0)
+
+    def test_a_constant_column_scales_to_zero_because_the_mad_collapses_to_one(self):
+        column = np.full((8, 1), 2.5)
+        fitted = transformer("mmad", "none")
+        fitted.fit(column)
+        scaled, *_ = fitted.transform(column)
+        np.testing.assert_allclose(scaled, 0.0, rtol=0.0, atol=0.0)
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_allclose(recovered, column, rtol=0.0, atol=0.0)
+
+    def test_a_mad_below_the_isclose_tolerance_is_treated_as_collapsed(self):
+        rng = np.random.default_rng(3)
+        column = 1.0 + rng.normal(scale=1e-10, size=(12, 1))
+        fitted = transformer("mmad", "none")
+        fitted.fit(column)
+        scaled, *_ = fitted.transform(column)
+        self.assertTrue(np.all(np.isfinite(scaled)))
+        # The true MAD is 5.45e-11, which would scale the residual to O(1e10); the collapse to
+        # 1.0 keeps the output at the residual's own scale, so this distinguishes the guard.
+        self.assertLess(float(np.max(np.abs(scaled))), 1e-6)
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_allclose(recovered, column, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL)
+
+    def test_a_non_finite_mad_is_replaced_instead_of_dividing_by_it(self):
+        # np.median of two equal near-maximal floats overflows to inf, so the median absolute
+        # deviation is non-finite too. Dividing by the replacement leaves -inf, whereas dividing
+        # by the inf MAD itself would emit NaN.
+        column = np.array([[1.7e308], [1.7e308]])
+        fitted = transformer("mmad", "none")
+        fitted.fit(column)
+        scaled, *_ = fitted.transform(column)
+        self.assertFalse(np.any(np.isnan(scaled)))
+        self.assertTrue(np.all(np.isneginf(scaled)))
+
+    def test_sigmoid_is_the_identity_when_its_scale_is_zero_or_not_finite(self):
+        series = label_series()
+        for scale in (0.0, 1e-12, np.inf, np.nan):
+            with self.subTest(sigmoid_scale=scale):
+                fitted = transformer("none", "sigmoid", sigmoid_scale=scale)
+                fitted.fit(series)
+                scaled, *_ = fitted.transform(series)
+                recovered, *_ = fitted.inverse_transform(scaled)
+                np.testing.assert_array_equal(scaled, series)
+                np.testing.assert_array_equal(recovered, series)
+
+    def test_the_sigmoid_inverse_clips_to_the_open_interval_and_stays_finite(self):
+        series = label_series()
+        fitted = transformer("none", "sigmoid")
+        fitted.fit(series)
+        with self.assertLogs("LabelTransformer", level="WARNING") as captured:
+            recovered, *_ = fitted.inverse_transform(np.array([-4.0, -1.0, 0.0, 1.0, 4.0]))
+        self.assertTrue(any("Clipped 4 value(s)" in line for line in captured.output))
+        self.assertTrue(np.all(np.isfinite(recovered)))
+        eps = np.finfo(float).eps
+        # logit((clip + 1) / 2) at clip = 1 - eps is the largest magnitude the inverse can emit.
+        bound = float(np.log((2.0 - eps) / eps))
+        self.assertLessEqual(float(np.max(np.abs(recovered))), bound)
+
+    def test_gamma_preserves_the_sign_and_is_the_identity_at_one(self):
+        series = label_series()
+        fitted = transformer("none", "none", gamma=0.5)
+        fitted.fit(series)
+        scaled, *_ = fitted.transform(series)
+        np.testing.assert_array_equal(np.sign(scaled), np.sign(series))
+        np.testing.assert_allclose(
+            scaled,
+            np.sign(series) * np.sqrt(np.abs(series)),
+            rtol=LABEL_ARRAY_RTOL,
+            atol=LABEL_ARRAY_ATOL,
+        )
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_allclose(recovered, series, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL)
+
+    def test_gamma_is_the_identity_at_one_and_at_a_degenerate_value(self):
+        series = label_series()
+        for gamma in (1.0, 0.0, -1.0, np.inf, np.nan):
+            with self.subTest(gamma=gamma):
+                fitted = transformer("none", "none", gamma=gamma)
+                fitted.fit(series)
+                scaled, *_ = fitted.transform(series)
+                recovered, *_ = fitted.inverse_transform(scaled)
+                np.testing.assert_array_equal(scaled, series)
+                np.testing.assert_array_equal(recovered, series)
+
+    def test_an_unknown_standardization_name_is_refused_at_fit_with_the_canonical_message(self):
+        with self.assertRaises(ValueError) as caught:
+            transformer("zigzag").fit(label_matrix())
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("standardization", "zigzag", STANDARDIZATION_TYPES),
+        )
+
+    def test_an_unknown_normalization_name_is_refused_at_fit_with_the_canonical_message(self):
+        with self.assertRaises(ValueError) as caught:
+            transformer("none", "softmax").fit(label_matrix())
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("normalization", "softmax", NORMALIZATION_TYPES),
+        )
+
+    def test_the_canonical_pipeline_defaults_are_not_mutated_by_construction(self):
+        before = dict(DEFAULTS_LABEL_PIPELINE)
+        transformer("zscore", "minmax", gamma=2.0).fit(label_matrix())
+        self.assertEqual(DEFAULTS_LABEL_PIPELINE, before)
+
+    def test_a_flat_config_dict_is_read_as_the_default_block(self):
+        # The regressor hands over whatever get_label_pipeline_config produced, which may carry
+        # pipeline keys at the top level instead of under "default".
+        matrix = label_matrix()
+        fitted = LabelTransformer(
+            label_transformer={"standardization": "zscore", "normalization": "minmax", "ignored": 1}
+        )
+        fitted.fit(matrix)
+        scaled, *_ = fitted.transform(matrix)
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_allclose(recovered, matrix, rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL)
+
+    def test_a_labelled_frame_round_trips_through_its_own_column_names(self):
+        frame = pd.DataFrame({"&-extrema": [1.0, -2.0, 0.5, 3.0], "other": [0.0, 1.0, -1.0, 2.0]})
+        fitted = transformer("zscore", "minmax")
+        fitted.fit(frame, feature_list=list(frame.columns))
+        scaled, *_ = fitted.transform(frame)
+        self.assertEqual(scaled.shape, frame.shape)
+        recovered, *_ = fitted.inverse_transform(scaled)
+        np.testing.assert_allclose(
+            recovered, frame.to_numpy(dtype=float), rtol=ROUND_TRIP_RTOL, atol=ROUND_TRIP_ATOL
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
index 2965d8ea6c3f13f4228cd7f801dce6360e6463f1..cb54022548d2279d65d1c1700a978762615058a3 100644 (file)
@@ -1,8 +1,6 @@
 """Runtime contracts for FreqAI's native QuickAdapter prediction history."""
 
-import tempfile
 import unittest
-from pathlib import Path
 from types import SimpleNamespace
 from unittest import mock
 
@@ -11,45 +9,16 @@ import pandas as pd
 from freqtrade.enums import RunMode
 from freqtrade.exchange import timeframe_to_seconds
 from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
+from qa_support import CONFIG_TEMPLATE, PAIR, QaTestCase, model_config, temporary_directory
 
 from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import QuickAdapterRegressorV3
 
-PAIR = "BTC/USDT"
 MARKER = "_freqai_strategies_produced"
 
 
-def model_config(path: str) -> dict:
-    return {
-        "user_data_dir": Path(path),
-        "timeframe": "5m",
-        "stake_amount": "unlimited",
-        "runmode": RunMode.DRY_RUN,
-        "exchange": {"pair_whitelist": [PAIR]},
-        "pairlists": [{"method": "StaticPairList"}],
-        "freqai": {
-            "enabled": True,
-            "identifier": "quickadapter-runtime-regression",
-            "continual_learning": True,
-            "train_period_days": 1,
-            "backtest_period_days": 1,
-            "conv_width": 1,
-            "fit_live_predictions_candles": 2,
-            "feature_parameters": {
-                "include_timeframes": ["5m"],
-                "include_corr_pairlist": [],
-                "label_period_candles": 1,
-                "shuffle_after_split": False,
-            },
-            "data_split_parameters": {"test_size": 0, "shuffle": False},
-            "model_training_parameters": {"n_estimators": 2, "n_jobs": 1},
-            "label_prediction": {"method": "none"},
-        },
-    }
-
-
-class PredictionHistoryTest(unittest.TestCase):
+class PredictionHistoryTest(QaTestCase):
     def test_native_append_restart_and_causal_calibration(self):
-        with tempfile.TemporaryDirectory() as temp:
+        with temporary_directory() as temp:
             config = model_config(temp)
             model = QuickAdapterRegressorV3(config=config)
             model.live = True
@@ -141,7 +110,7 @@ class PredictionHistoryTest(unittest.TestCase):
             )
 
     def test_backtest_does_not_use_future_model_but_resumes_from_earlier_artifact(self):
-        with tempfile.TemporaryDirectory() as temp:
+        with temporary_directory() as temp:
             config = model_config(temp)
             pair = PAIR
 
@@ -181,9 +150,7 @@ class PredictionHistoryTest(unittest.TestCase):
             backtest_config = dict(config)
             backtest_config["runmode"] = RunMode.BACKTEST
             backtest_config["timerange"] = "20260101-20260105"
-            backtest_config["config_files"] = [
-                "/workspace/quickadapter/user_data/config-template.json"
-            ]
+            backtest_config["config_files"] = [str(CONFIG_TEMPLATE)]
             backtest = QuickAdapterRegressorV3(config=backtest_config)
             backtest.live = False
             for day, offset, save_model in (
diff --git a/quickadapter/tests/test_regressor_data_split.py b/quickadapter/tests/test_regressor_data_split.py
new file mode 100644 (file)
index 0000000..60217c2
--- /dev/null
@@ -0,0 +1,418 @@
+"""Causal purge and chronological split contracts; requires the Freqtrade QA image."""
+
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import QaTestCase, model_config, temporary_directory
+from Utils import (
+    LABEL_COLUMNS,
+    label_known_at_lookahead_column_name,
+    label_weight_known_at_lookahead_column_name,
+)
+
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import (
+    QuickAdapterRegressorV3,
+    SampleWeightInputs,
+)
+
+PAIR = "BTC/USDT"
+LABEL = LABEL_COLUMNS[0]
+LOOKAHEAD = label_known_at_lookahead_column_name(LABEL)
+WEIGHT_LOOKAHEAD = label_weight_known_at_lookahead_column_name(LABEL)
+WEIGHTING_CONFIG = {
+    "support_policy": "fallback",
+    "min_pivot_equivalent_count": 1,
+    "min_positive_label_weight_fraction": 0.0,
+    "min_effective_sample_size": 1.0,
+}
+
+
+def regressor(**attrs: object) -> QuickAdapterRegressorV3:
+    """Build a regressor without running __init__, injecting only what the code under test reads."""
+    model = object.__new__(QuickAdapterRegressorV3)
+    model.ft_params = attrs.pop("ft_params", {"label_period_candles": 1})
+    model.data_split_parameters = attrs.pop(
+        "data_split_parameters", {"test_size": 0, "shuffle": False}
+    )
+    model._causal_mode = attrs.pop("_causal_mode", True)
+    model._label_horizon_candles = lambda pair=None: 0
+    for name, value in attrs.items():
+        setattr(model, name, value)
+    return model
+
+
+def frame(index: pd.Index, **columns: object) -> pd.DataFrame:
+    return pd.DataFrame({**columns}, index=index)
+
+
+class IndexAlignmentTest(QaTestCase):
+    def test_non_unique_unfiltered_index_is_refused(self):
+        duplicated = pd.Index([0, 1, 1])
+        with self.assertRaisesRegex(ValueError, "must be unique"):
+            QuickAdapterRegressorV3._validate_index_alignment(frame(duplicated), frame(duplicated))
+
+    def test_filtered_index_outside_unfiltered_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "must be a subset"):
+            QuickAdapterRegressorV3._validate_index_alignment(
+                frame(pd.Index([0, 9])), frame(pd.Index([0, 1, 2]))
+            )
+
+    def test_row_positions_are_the_unfiltered_row_ordinals(self):
+        unfiltered = frame(pd.Index([10, 11, 12, 13]))
+        positions = QuickAdapterRegressorV3._row_positions(frame(pd.Index([12, 10])), unfiltered)
+        np.testing.assert_array_equal(positions.to_numpy(), np.array([2, 0]))
+        self.assertEqual(list(positions.index), [12, 10])
+
+    def test_row_positions_preserve_the_filtered_row_order(self):
+        unfiltered = frame(pd.Index([0, 1, 2, 3]))
+        positions = QuickAdapterRegressorV3._row_positions(frame(pd.Index([3, 1])), unfiltered)
+        self.assertEqual(list(positions.index), [3, 1])
+
+
+class KnownAtLookaheadTest(QaTestCase):
+    def test_returns_none_when_no_lookahead_column_is_emitted(self):
+        unfiltered = frame(pd.Index([0, 1]))
+        self.assertIsNone(QuickAdapterRegressorV3._known_at_lookahead(unfiltered, unfiltered))
+
+    def test_single_column_is_returned_as_int64(self):
+        unfiltered = frame(pd.Index([0, 1]), **{LOOKAHEAD: [3, 7]})
+        result = QuickAdapterRegressorV3._known_at_lookahead(unfiltered, unfiltered)
+        self.assertEqual(result.dtype, np.dtype(np.int64))
+        np.testing.assert_array_equal(result.to_numpy(), np.array([3, 7]))
+
+    def test_weight_availability_lags_the_label_by_a_row_wise_max(self):
+        unfiltered = frame(pd.Index([0, 1]), **{LOOKAHEAD: [3, 1], WEIGHT_LOOKAHEAD: [9, 1]})
+        result = QuickAdapterRegressorV3._known_at_lookahead(unfiltered, unfiltered)
+        np.testing.assert_array_equal(result.to_numpy(), np.array([9, 1]))
+
+    def test_a_column_holding_nan_is_skipped_entirely(self):
+        unfiltered = frame(
+            pd.Index([0, 1]),
+            **{LOOKAHEAD: [3.0, np.nan], WEIGHT_LOOKAHEAD: [9, 9]},
+        )
+        result = QuickAdapterRegressorV3._known_at_lookahead(unfiltered, unfiltered)
+        np.testing.assert_array_equal(result.to_numpy(), np.array([9, 9]))
+
+    def test_result_is_aligned_to_the_filtered_index(self):
+        unfiltered = frame(pd.Index([0, 1, 2]), **{LOOKAHEAD: [5, 6, 7]})
+        result = QuickAdapterRegressorV3._known_at_lookahead(frame(pd.Index([2, 0])), unfiltered)
+        self.assertEqual(list(result.index), [2, 0])
+        np.testing.assert_array_equal(result.to_numpy(), np.array([7, 5]))
+
+
+class FilterTrainByMaskTest(QaTestCase):
+    def setUp(self):
+        super().setUp()
+        self.features = frame(pd.Index([0, 1, 2]), f=[1.0, 2.0, 3.0])
+        self.labels = frame(pd.Index([0, 1, 2]), y=[10.0, 20.0, 30.0])
+        self.weights = np.array([1.0, 2.0, 3.0])
+        self.label_weights = np.array([0.1, 0.2, 0.3])
+
+    def test_rows_are_sliced_in_lockstep_across_all_four_carriers(self):
+        mask = np.array([True, False, True])
+        features, labels, weights, label_weights = QuickAdapterRegressorV3._filter_train_by_mask(
+            self.features, self.labels, self.weights, mask, "ctx", self.label_weights
+        )
+        self.assertEqual(list(features.index), [0, 2])
+        self.assertEqual(list(labels.index), [0, 2])
+        np.testing.assert_array_equal(weights, np.array([1.0, 3.0]))
+        np.testing.assert_array_equal(label_weights, np.array([0.1, 0.3]))
+
+    def test_absent_label_weights_stay_absent(self):
+        _, _, _, label_weights = QuickAdapterRegressorV3._filter_train_by_mask(
+            self.features, self.labels, self.weights, np.array([True, True, True]), "ctx"
+        )
+        self.assertIsNone(label_weights)
+
+    def test_an_entirely_removed_training_set_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "causal guard removed all train rows"):
+            QuickAdapterRegressorV3._filter_train_by_mask(
+                self.features, self.labels, self.weights, np.array([False, False, False]), "ctx"
+            )
+
+    def test_a_single_surviving_row_is_enough(self):
+        features, _, weights, _ = QuickAdapterRegressorV3._filter_train_by_mask(
+            self.features, self.labels, self.weights, np.array([False, True, False]), "ctx"
+        )
+        self.assertEqual(list(features.index), [1])
+        np.testing.assert_array_equal(weights, np.array([2.0]))
+
+
+class ShuffleSplitRowsTest(QaTestCase):
+    def test_weights_travel_with_their_shuffled_rows(self):
+        features = frame(pd.Index([0, 1, 2, 3]), f=[10.0, 20.0, 30.0, 40.0])
+        labels = frame(pd.Index([0, 1, 2, 3]), y=[1.0, 2.0, 3.0, 4.0])
+        base = np.array([1.0, 2.0, 3.0, 4.0])
+        label_weights = np.array([0.1, 0.2, 0.3, 0.4])
+
+        shuffled, shuffled_labels, shuffled_base, shuffled_label_weights = (
+            QuickAdapterRegressorV3._shuffle_split_rows(
+                features, labels, base, label_weights, seed=7
+            )
+        )
+        for position, index in enumerate(shuffled.index):
+            with self.subTest(row=index):
+                self.assertEqual(float(shuffled["f"].iloc[position]), 10.0 * (index + 1))
+                self.assertEqual(float(shuffled_labels["y"].iloc[position]), float(index + 1))
+                self.assertEqual(float(shuffled_base[position]), float(index + 1))
+                self.assertAlmostEqual(float(shuffled_label_weights[position]), (index + 1) / 10.0)
+
+    def test_the_same_seed_reproduces_the_same_order(self):
+        features = frame(pd.Index(range(8)), f=np.arange(8.0))
+        labels = frame(pd.Index(range(8)), y=np.arange(8.0))
+        base = np.arange(8.0)
+        first, *_ = QuickAdapterRegressorV3._shuffle_split_rows(
+            features, labels, base, None, seed=11
+        )
+        second, *_ = QuickAdapterRegressorV3._shuffle_split_rows(
+            features, labels, base, None, seed=11
+        )
+        self.assertEqual(list(first.index), list(second.index))
+
+    def test_a_different_seed_may_reorder_the_rows(self):
+        features = frame(pd.Index(range(8)), f=np.arange(8.0))
+        labels = frame(pd.Index(range(8)), y=np.arange(8.0))
+        base = np.arange(8.0)
+        orders = {
+            tuple(
+                QuickAdapterRegressorV3._shuffle_split_rows(
+                    features, labels, base, None, seed=seed
+                )[0].index
+            )
+            for seed in range(8)
+        }
+        self.assertGreater(len(orders), 1)
+
+
+class KnownBeforePositionMaskTest(QaTestCase):
+    def setUp(self):
+        super().setUp()
+        self.unfiltered = frame(pd.Index(range(10)))
+
+    def test_a_row_known_exactly_at_the_cutoff_is_excluded(self):
+        model = regressor()
+        model._label_horizon_candles = lambda pair=None: 0
+        unfiltered = frame(pd.Index(range(10)), **{LOOKAHEAD: np.zeros(10, dtype=np.int64)})
+        features = frame(pd.Index([0, 3, 4, 5]))
+        mask = model._known_before_position_mask(features, unfiltered, PAIR, cutoff_position=4)
+        # A zero lookahead makes availability the row position itself, so the row AT the cutoff
+        # is the boundary case: the comparison is strict, so it must be excluded.
+        np.testing.assert_array_equal(mask, np.array([True, True, False, False]))
+
+    def test_the_cutoff_is_exclusive_on_both_horizons(self):
+        model = regressor()
+        model._label_horizon_candles = lambda pair=None: 0
+        unfiltered = frame(pd.Index(range(10)), **{LOOKAHEAD: np.zeros(10, dtype=np.int64)})
+        features = frame(pd.Index([0, 3]))
+        for cutoff, expected in ((3, [True, False]), (4, [True, True]), (5, [True, True])):
+            with self.subTest(cutoff=cutoff):
+                mask = model._known_before_position_mask(features, unfiltered, PAIR, cutoff)
+                np.testing.assert_array_equal(mask, np.array(expected))
+
+    def test_the_position_fallback_applies_when_no_column_is_emitted(self):
+        model = regressor()
+        model._label_horizon_candles = lambda pair=None: 2
+        features = frame(pd.Index([0, 1, 2]))
+        mask = model._known_before_position_mask(features, self.unfiltered, PAIR, cutoff_position=5)
+        np.testing.assert_array_equal(mask, np.array([0 + 2 < 5, 1 + 2 < 5, 2 + 2 < 5]))
+
+    def test_the_fallback_horizon_shifts_the_cutoff(self):
+        wide = regressor()
+        wide._label_horizon_candles = lambda pair=None: 0
+        narrow = regressor()
+        narrow._label_horizon_candles = lambda pair=None: 3
+        features = frame(pd.Index([0, 1, 2]))
+        loose = wide._known_before_position_mask(features, self.unfiltered, PAIR, 3)
+        strict = narrow._known_before_position_mask(features, self.unfiltered, PAIR, 3)
+        self.assertEqual(loose.sum(), 3)
+        self.assertEqual(strict.sum(), 0)
+
+
+class ValidationSplitTest(QaTestCase):
+    def setUp(self):
+        super().setUp()
+        self.unfiltered = frame(pd.Index(range(12)))
+        self.features = frame(pd.Index(range(8)), f=np.arange(8.0))
+        self.weights = SampleWeightInputs(
+            base=np.arange(1.0, 9.0), label=None, label_weighting_config=WEIGHTING_CONFIG
+        )
+        self.data_dictionary = {
+            "train_features": self.features,
+            "train_labels": frame(pd.Index(range(8)), y=np.arange(8.0)),
+            "train_weights": np.ones(8),
+            "test_features": frame(pd.Index(range(8, 12)), f=np.arange(8.0, 12.0)),
+            "test_labels": frame(pd.Index(range(8, 12)), y=np.arange(8.0, 12.0)),
+            "test_weights": np.ones(4),
+        }
+
+    def _model(self, **attrs: object) -> QuickAdapterRegressorV3:
+        return regressor(**attrs)
+
+    def _split(self, model: QuickAdapterRegressorV3, **kwargs: object) -> dict:
+        return model._add_validation_split(
+            dict(self.data_dictionary),
+            self.features,
+            self.weights,
+            self.unfiltered,
+            PAIR,
+            **kwargs,
+        )
+
+    def test_a_zero_test_size_yields_empty_validation_frames(self):
+        model = self._model(data_split_parameters={"test_size": 0, "shuffle": False})
+        result = self._split(model)
+        self.assertTrue(result["validation_features"].empty)
+        self.assertTrue(result["validation_labels"].empty)
+        self.assertEqual(len(result["validation_weights"]), 0)
+
+    def test_a_zero_test_size_leaves_the_training_rows_untouched(self):
+        model = self._model(data_split_parameters={"test_size": 0, "shuffle": False})
+        result = self._split(model)
+        self.assertEqual(list(result["train_features"].index), list(range(8)))
+
+    def test_a_shuffled_configuration_is_refused(self):
+        for key, source in (
+            ("shuffle", {"data_split_parameters": {"test_size": 2, "shuffle": True}}),
+            ("shuffle_after_split", {"ft_params": {"shuffle_after_split": True}}),
+            ("reverse_train_test_order", {"ft_params": {"reverse_train_test_order": True}}),
+        ):
+            with self.subTest(flag=key):
+                # A validation split must actually be requested: the guard sits behind the
+                # test_size == 0 early return, so a zero-size configuration never reaches it.
+                model = self._model(
+                    **{"data_split_parameters": {"test_size": 2, "shuffle": False}, **source}
+                )
+                with self.assertRaisesRegex(ValueError, "Independent holdout evaluation requires"):
+                    self._split(model)
+
+    def test_a_zero_size_configuration_returns_before_the_shuffle_guard(self):
+        model = self._model(
+            data_split_parameters={"test_size": 0, "shuffle": True},
+            ft_params={"shuffle_after_split": True},
+        )
+        result = self._split(model)
+        self.assertTrue(result["validation_features"].empty)
+
+    def test_the_chronological_tail_is_reserved_not_sampled(self):
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = self._split(model)
+        self.assertEqual(list(result["validation_features"].index), [6, 7])
+        self.assertEqual(list(result["train_features"].index), [0, 1, 2, 3, 4, 5])
+
+    def test_the_purge_leaves_no_overlap_with_the_validation_tail(self):
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = self._split(model)
+        self.assertTrue(
+            set(result["train_features"].index).isdisjoint(result["validation_features"].index)
+        )
+
+    def test_a_wider_horizon_purges_more_training_rows(self):
+        kept = {}
+        for horizon in (0, 1, 3):
+            with self.subTest(horizon=horizon):
+                model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+                model._label_horizon_candles = lambda pair=None, h=horizon: h
+                result = self._split(model)
+                kept[horizon] = len(result["train_features"])
+        self.assertGreater(kept[0], kept[1])
+        self.assertGreater(kept[1], kept[3])
+
+    def test_the_weights_are_recomposed_on_the_split_rows_not_sliced(self):
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = self._split(model)
+        self.assertEqual(len(result["train_weights"]), len(result["train_features"]))
+        self.assertEqual(len(result["validation_weights"]), len(result["validation_features"]))
+        # A recomposition renormalises each subset to mean 1, so the recomposed train weights
+        # cannot equal the raw prefix of the base weights the caller passed in.
+        self.assertAlmostEqual(float(np.mean(result["train_weights"])), 1.0)
+        self.assertAlmostEqual(float(np.mean(result["validation_weights"])), 1.0)
+
+    def test_holdout_rows_available_before_the_window_end_are_kept(self):
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = self._split(model)
+        self.assertEqual(list(result["test_features"].index), [8, 9, 10, 11])
+        self.assertNotIn("holdout_purged_empty", result)
+
+    def test_a_fully_purged_holdout_is_flagged_rather_than_scored(self):
+        # A lookahead of 4 makes row 8 available at position 12, which is the window end and
+        # therefore not strictly before it: every holdout row is purged.
+        unfiltered = self.unfiltered.assign(**{LOOKAHEAD: np.full(12, 4, dtype=np.int64)})
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = model._add_validation_split(
+            dict(self.data_dictionary), self.features, self.weights, unfiltered, PAIR
+        )
+        self.assertTrue(result["test_features"].empty)
+        self.assertTrue(result.get("holdout_purged_empty"))
+
+    def test_a_partially_purged_holdout_keeps_the_available_rows(self):
+        unfiltered = self.unfiltered.assign(**{LOOKAHEAD: np.full(12, 2, dtype=np.int64)})
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = model._add_validation_split(
+            dict(self.data_dictionary), self.features, self.weights, unfiltered, PAIR
+        )
+        # Rows 8 and 9 are available at 10 and 11, both strictly before the window end.
+        self.assertEqual(list(result["test_features"].index), [8, 9])
+        self.assertNotIn("holdout_purged_empty", result)
+
+    def test_a_lookahead_column_replaces_the_position_fallback(self):
+        unfiltered = self.unfiltered.assign(**{LOOKAHEAD: np.full(12, 4, dtype=np.int64)})
+        model = self._model(data_split_parameters={"test_size": 2, "shuffle": False})
+        result = model._add_validation_split(
+            dict(self.data_dictionary),
+            self.features,
+            self.weights,
+            unfiltered,
+            PAIR,
+        )
+        # Validation starts at position 6; a lookahead of 4 keeps only positions 0 and 1.
+        self.assertEqual(list(result["validation_features"].index), [6, 7])
+        self.assertEqual(list(result["train_features"].index), [0, 1])
+
+
+class ConstructionContractTest(QaTestCase):
+    """The fixtures above bypass __init__; this pins the invariants they assume."""
+
+    def test_a_real_regressor_exposes_the_attributes_the_purge_fixtures_inject(self):
+        with temporary_directory() as temp:
+            model = QuickAdapterRegressorV3(config=model_config(temp))
+        for attribute in ("ft_params", "data_split_parameters", "data_provider"):
+            with self.subTest(attribute=attribute):
+                self.assertTrue(hasattr(model, attribute))
+
+    def test_a_real_regressor_derives_its_label_horizon_from_ft_params(self):
+        with temporary_directory() as temp:
+            model = QuickAdapterRegressorV3(config=model_config(temp))
+        self.assertEqual(model._label_horizon_candles(), 1)
+
+    def test_sample_weight_inputs_refuse_a_two_dimensional_base(self):
+        with self.assertRaisesRegex(ValueError, "must be 1-D"):
+            SampleWeightInputs(
+                base=np.ones((2, 2)), label=None, label_weighting_config=WEIGHTING_CONFIG
+            )
+
+    def test_sample_weight_inputs_refuse_mismatched_label_weights(self):
+        with self.assertRaisesRegex(ValueError, "shape"):
+            SampleWeightInputs(
+                base=np.ones(4), label=np.ones(3), label_weighting_config=WEIGHTING_CONFIG
+            )
+
+    def test_sample_weight_inputs_refuse_an_incomplete_weighting_config(self):
+        with self.assertRaisesRegex(KeyError, "min_effective_sample_size"):
+            SampleWeightInputs(
+                base=np.ones(4),
+                label=None,
+                label_weighting_config={"support_policy": "fallback"},
+            )
+
+    def test_sample_weight_inputs_refuse_an_unknown_support_policy(self):
+        with self.assertRaisesRegex(ValueError, "support_policy"):
+            SampleWeightInputs(
+                base=np.ones(4),
+                label=None,
+                label_weighting_config={**WEIGHTING_CONFIG, "support_policy": "ignore"},
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_regressor_label_config.py b/quickadapter/tests/test_regressor_label_config.py
new file mode 100644 (file)
index 0000000..caa38c4
--- /dev/null
@@ -0,0 +1,971 @@
+"""Label-method configuration, validation and Optuna sampler/pool contracts.
+
+Covers the config-to-object boundary: which misconfigurations are fatal and which
+are downgraded with a warning, plus the hermetic Optuna sampler mapping and the
+label candle pool that feeds label selection. Requires the Freqtrade QA image.
+"""
+
+import json
+import random
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+import pandas as pd
+import QuickAdapterV3 as strategy_module
+from LabelTransformer import (
+    CUSTOM_THRESHOLD_METHODS,
+    PREDICTION_METHODS,
+    SKIMAGE_THRESHOLD_METHODS,
+)
+from qa_support import PAIR, QaTestCase, model_config, temporary_directory
+from QuickAdapterV3 import QuickAdapterV3
+from Utils import _OPTUNA_LABEL_SELECTION_SCHEMA_VERSION, _OPTUNA_NAMESPACES, enum_error_message
+
+import quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 as regressor_module
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import (
+    QuickAdapterRegressorV3,
+)
+
+REGRESSOR_LOGGER = "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3"
+# Two active objectives, neither collinear with the other, so both standardized
+# distance metrics have a positive-definite covariance to work with.
+REFERENCE_MATRIX = np.array(
+    [
+        [0.0, 0.0],
+        [1.0, 2.0],
+        [2.0, 1.0],
+        [3.0, 4.0],
+    ]
+)
+# Rank-deficient: the second column is exactly twice the first.
+COLLINEAR_MATRIX = np.array(
+    [
+        [0.0, 0.0],
+        [1.0, 2.0],
+        [2.0, 4.0],
+        [3.0, 6.0],
+    ]
+)
+NO_ENTRY_SIGNALS = -2.0, 2.0
+
+
+def label_regressor(**attributes: object) -> QuickAdapterRegressorV3:
+    """Build a regressor without running __init__, injecting only what the code under test reads."""
+    model = object.__new__(QuickAdapterRegressorV3)
+    model.ft_params = attributes.pop("ft_params", {})
+    for name, value in attributes.items():
+        setattr(model, name, value)
+    return model
+
+
+def candle_regressor(
+    pairs: list[str], label_frequency_candles: int, seed: int
+) -> QuickAdapterRegressorV3:
+    """Build a regressor wired for the label candle pool, with a seeded shuffle RNG."""
+    return label_regressor(
+        pairs=list(pairs),
+        ft_params={"label_frequency_candles": label_frequency_candles},
+        _optuna_label_candle_pool_full_cache={},
+        _optuna_label_shuffle_rng=random.Random(seed),
+        _optuna_label_candle_pool=[],
+        _optuna_label_candle={},
+        _optuna_label_candles=dict.fromkeys(pairs, 0),
+        _optuna_label_incremented_pairs=[],
+    )
+
+
+def optuna_config(**overrides: object) -> dict:
+    """The subset of freqai.optuna_hyperopt that the sampler and pruner constructors read."""
+    return {
+        "sampler": "tpe",
+        "label_sampler": "tpe",
+        "n_startup_trials": 15,
+        "n_jobs": 1,
+        "seed": 1,
+        "min_resource": 3,
+        **overrides,
+    }
+
+
+def min_max_config(**overrides: object) -> dict:
+    """A col_prediction_config with every key min_max_pred reads, plus the ``method`` gate."""
+    return {
+        "method": PREDICTION_METHODS[1],  # "thresholding"
+        "selection_method": "rank_extrema",
+        "threshold_method": SKIMAGE_THRESHOLD_METHODS[0],  # "mean"
+        "outlier_quantile": 0.999,
+        "soft_extremum_alpha": 12.0,
+        "keep_fraction": 1.0,
+        **overrides,
+    }
+
+
+def floored_pseudo_inverse(reference_matrix: np.ndarray) -> np.ndarray:
+    """Reproduce the eigenvalue-floored inverse the mahalanobis branch is specified to build."""
+    covariance = np.atleast_2d(np.cov(reference_matrix, rowvar=False, ddof=1))
+    eigenvalues, eigenvectors = np.linalg.eigh(covariance)
+    floor = eigenvalues[-1] * np.sqrt(np.finfo(float).eps)
+    return (eigenvectors / np.maximum(eigenvalues, floor)) @ eigenvectors.T
+
+
+def json_roundtrip(metadata: dict) -> object:
+    """Round-trip through json so a numpy value in the marker cannot hide behind dict equality."""
+    return json.loads(json.dumps(metadata))
+
+
+class RegressorLabelConfigTest(QaTestCase):
+    def test_unknown_label_method_raises_with_the_canonical_enum_message(self):
+        with self.assertRaises(ValueError) as caught:
+            label_regressor()._resolve_label_method_config("kde")
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("label_method", "kde", QuickAdapterRegressorV3._SELECTION_METHODS),
+        )
+
+    def test_category_defaults_are_resolved_per_method(self):
+        expected = {
+            "topsis": {
+                "category": "distance",
+                "method": "topsis",
+                "distance_metric": "euclidean",
+            },
+            "kmeans": {
+                "category": "cluster",
+                "method": "kmeans",
+                "distance_metric": "euclidean",
+                "selection_method": "topsis",
+                "trial_selection_method": "topsis",
+            },
+            "knn": {
+                "category": "density",
+                "method": "knn",
+                "distance_metric": "minkowski",
+                "aggregation": "power_mean",
+                "n_neighbors": 5,
+                "aggregation_param": 1.0,
+            },
+            "medoid": {
+                "category": "density",
+                "method": "medoid",
+                "distance_metric": "euclidean",
+            },
+        }
+        for method, config in expected.items():
+            with self.subTest(method=method):
+                resolved = label_regressor()._resolve_label_method_config(method)
+                self.assertEqual(resolved, config)
+
+    def test_distance_category_accepts_an_aggregate_metric(self):
+        resolved = label_regressor(ft_params={"label_distance_metric": "weighted_sum"})
+        self.assertEqual(
+            resolved._resolve_label_method_config("topsis")["distance_metric"],
+            "weighted_sum",
+        )
+
+    def test_bad_distance_metric_warns_and_substitutes_the_category_default(self):
+        cases = {
+            "topsis": ("label_distance_metric", "jaccard", "euclidean"),
+            "kmeans": ("label_cluster_metric", "power_mean", "euclidean"),
+            "knn": ("label_density_metric", "weighted_sum", "minkowski"),
+            "medoid": ("label_density_metric", "hellinger", "euclidean"),
+        }
+        for method, (key, bad_metric, expected_default) in cases.items():
+            with self.subTest(method=method):
+                model = label_regressor(ft_params={key: bad_metric})
+                with self.assertLogs(REGRESSOR_LOGGER, level="WARNING") as logs:
+                    resolved = model._resolve_label_method_config(method)
+                self.assertEqual(resolved["distance_metric"], expected_default)
+                self.assertIn(f"Invalid {key} value {bad_metric!r}", "\n".join(logs.output))
+
+    def test_cluster_and_cluster_trial_selection_methods_are_fatal(self):
+        for key in ("label_cluster_selection_method", "label_cluster_trial_selection_method"):
+            with self.subTest(key=key):
+                model = label_regressor(ft_params={key: "kalman"})
+                with self.assertRaises(ValueError) as caught:
+                    model._resolve_label_method_config("kmeans")
+                self.assertEqual(
+                    str(caught.exception),
+                    enum_error_message(key, "kalman", QuickAdapterRegressorV3._DISTANCE_METHODS),
+                )
+
+    def test_aggregate_metric_is_rejected_for_cluster_and_density_categories(self):
+        aggregates = set(QuickAdapterRegressorV3._POWER_MEAN_MAP) | {"weighted_sum"}
+        for aggregate in aggregates:
+            with self.subTest(aggregate=aggregate):
+                with self.assertRaises(ValueError) as caught:
+                    QuickAdapterRegressorV3._validate_label_selection_metric(
+                        aggregate,
+                        ctx="label_cluster_metric",
+                        default="euclidean",
+                        aggregate_allowed=False,
+                        mode="raise",
+                    )
+                self.assertEqual(
+                    str(caught.exception),
+                    enum_error_message(
+                        "label_cluster_metric",
+                        aggregate,
+                        tuple(
+                            candidate
+                            for candidate in QuickAdapterRegressorV3._DISTANCE_METRICS
+                            if candidate
+                            in QuickAdapterRegressorV3._CLUSTER_DENSITY_DISTANCE_METRICS_SET
+                        ),
+                    ),
+                )
+
+    def test_probability_metrics_are_rejected_for_cluster_and_density_categories(self):
+        for probability_metric in QuickAdapterRegressorV3._PROBABILITY_DISTANCE_METRICS_SET:
+            with self.subTest(metric=probability_metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._validate_label_selection_metric(
+                        probability_metric,
+                        ctx="label_density_metric",
+                        default="minkowski",
+                        aggregate_allowed=False,
+                        mode="none",
+                    ),
+                    "minkowski",
+                )
+
+    def test_knn_density_aggregation_and_neighbour_bounds_are_fatal(self):
+        cases = {
+            "label_density_aggregation": ("median", "supported values are power_mean"),
+            "label_density_n_neighbors": (0, "must be int >= 1"),
+            "label_density_aggregation_param": (1.5, "must be in \\[0, 1\\]"),
+        }
+        for key, (value, fragment) in cases.items():
+            with self.subTest(key=key):
+                ft_params = {"label_density_aggregation": "quantile"}
+                ft_params[key] = value
+                model = label_regressor(ft_params=ft_params)
+                with self.assertRaisesRegex(ValueError, fragment):
+                    model._resolve_label_method_config("knn")
+
+    def test_knn_aggregation_param_defaults_follow_the_aggregation(self):
+        expected = {
+            "power_mean": 1.0,
+            "quantile": 0.5,
+            "min": None,
+            "max": None,
+        }
+        for aggregation, default in expected.items():
+            with self.subTest(aggregation=aggregation):
+                model = label_regressor(ft_params={"label_density_aggregation": aggregation})
+                resolved = model._resolve_label_method_config("knn")
+                self.assertEqual(resolved["aggregation"], aggregation)
+                self.assertEqual(resolved["aggregation_param"], default)
+
+    def test_knn_aggregation_param_is_taken_from_the_config_when_present(self):
+        model = label_regressor(
+            ft_params={
+                "label_density_aggregation": "quantile",
+                "label_density_aggregation_param": 0.25,
+            }
+        )
+        self.assertEqual(model._resolve_label_method_config("knn")["aggregation_param"], 0.25)
+
+    def test_resolve_p_order_applies_the_minkowski_power_default_per_category(self):
+        cases = {
+            "minkowski": 2.0,
+            "power_mean": 1.0,
+            "euclidean": None,
+            "cityblock": None,
+        }
+        for metric, expected in cases.items():
+            with self.subTest(metric=metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._resolve_p_order(metric, None, ctx="label_p_order"),
+                    expected,
+                )
+
+    def test_resolve_p_order_prefers_the_configured_value_over_the_default(self):
+        for metric in ("minkowski", "power_mean", "euclidean"):
+            with self.subTest(metric=metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._resolve_p_order(metric, 7.5, ctx="label_p_order"),
+                    7.5,
+                )
+
+    def test_resolve_p_order_refuses_a_non_positive_minkowski_power(self):
+        with self.assertRaisesRegex(ValueError, "must be > 0"):
+            QuickAdapterRegressorV3._resolve_p_order(
+                "minkowski", -1.0, ctx="label_p_order", mode="raise"
+            )
+        for mode in ("warn", "none"):
+            with self.subTest(mode=mode):
+                if mode == "warn":
+                    with self.assertLogs(REGRESSOR_LOGGER, level="WARNING"):
+                        resolved = QuickAdapterRegressorV3._resolve_p_order(
+                            "minkowski", 0.0, ctx="label_p_order", mode=mode
+                        )
+                else:
+                    resolved = QuickAdapterRegressorV3._resolve_p_order(
+                        "minkowski", 0.0, ctx="label_p_order", mode=mode
+                    )
+                self.assertIsNone(resolved)
+
+    def test_label_weights_are_normalized_to_a_unit_sum(self):
+        cases = {
+            (1.0, 3.0): [0.25, 0.75],
+            (2.0, 2.0): [0.5, 0.5],
+            (0.1, 0.2, 0.7): [0.1, 0.2, 0.7],
+            (1e-9, 1e-9): [0.5, 0.5],
+            (0.0, 5.0): [0.0, 1.0],
+            # Dividing by the maximum before summing is what keeps a weight vector
+            # that would overflow on summation from collapsing to zero.
+            (1e308, 1e308): [0.5, 0.5],
+            (1e-320, 1e-320): [0.5, 0.5],
+        }
+        for weights, expected in cases.items():
+            with self.subTest(weights=weights):
+                resolved = QuickAdapterRegressorV3._validate_label_weights(
+                    list(weights), len(weights), ctx="label_weights"
+                )
+                np.testing.assert_allclose(resolved, np.array(expected), rtol=1e-12, atol=0.0)
+                np.testing.assert_allclose(resolved.sum(), 1.0, rtol=1e-12, atol=0.0)
+
+    def test_absent_label_weights_resolve_to_the_uniform_vector(self):
+        for n_objectives in (1, 2, 4, 7):
+            with self.subTest(n_objectives=n_objectives):
+                resolved = QuickAdapterRegressorV3._validate_label_weights(
+                    None, n_objectives, ctx="label_weights"
+                )
+                np.testing.assert_allclose(
+                    resolved,
+                    np.full(n_objectives, 1.0 / n_objectives),
+                    rtol=1e-12,
+                    atol=0.0,
+                )
+
+    def test_label_weights_refuse_non_finite_and_non_positive_vectors(self):
+        cases = {
+            (np.inf, 1.0): "contains non-finite values",
+            (np.nan, 1.0): "contains non-finite values",
+            (-1.0, 2.0): "contains negative values",
+            (0.0, 0.0): "sum is zero",
+        }
+        for weights, fragment in cases.items():
+            with (
+                self.subTest(weights=weights),
+                self.assertRaisesRegex(ValueError, fragment),
+            ):
+                QuickAdapterRegressorV3._validate_label_weights(
+                    list(weights), 2, ctx="label_weights"
+                )
+
+    def test_label_weights_refuse_a_wrong_length_or_a_non_vector(self):
+        with self.assertRaisesRegex(ValueError, "must contain 2 weights"):
+            QuickAdapterRegressorV3._validate_label_weights([1.0], 2, ctx="label_weights")
+        with self.assertRaisesRegex(ValueError, "must be a one-dimensional vector"):
+            QuickAdapterRegressorV3._validate_label_weights([[1.0, 2.0]], 2, ctx="label_weights")
+        with self.assertRaisesRegex(ValueError, "must be a list, tuple, or array"):
+            QuickAdapterRegressorV3._validate_label_weights("0.5,0.5", 2, ctx="label_weights")
+
+    def test_downgraded_label_weights_fall_back_to_uniform(self):
+        for mode in ("warn", "none"):
+            for weights in ([np.inf, 1.0], [-1.0, 1.0], [1.0]):
+                with self.subTest(mode=mode, weights=weights):
+                    if mode == "warn":
+                        with self.assertLogs(REGRESSOR_LOGGER, level="WARNING"):
+                            resolved = QuickAdapterRegressorV3._validate_label_weights(
+                                weights, 2, ctx="label_weights", mode=mode
+                            )
+                    else:
+                        resolved = QuickAdapterRegressorV3._validate_label_weights(
+                            weights, 2, ctx="label_weights", mode=mode
+                        )
+                    np.testing.assert_allclose(resolved, np.full(2, 0.5), rtol=1e-12, atol=0.0)
+
+    def test_label_weights_never_mutate_the_caller_array(self):
+        caller = np.array([1.0, 3.0])
+        resolved = QuickAdapterRegressorV3._validate_label_weights(caller, 2, ctx="label_weights")
+        np.testing.assert_array_equal(caller, np.array([1.0, 3.0]))
+        self.assertFalse(np.shares_memory(caller, resolved))
+
+    def test_metric_weights_support_refuses_metrics_that_cannot_carry_weights(self):
+        unsupported = QuickAdapterRegressorV3._UNSUPPORTED_WEIGHTS_METRICS_SET
+        self.assertEqual(unsupported, {"mahalanobis", "seuclidean", "jensenshannon"})
+        for metric in unsupported:
+            with self.subTest(metric=metric):
+                with self.assertRaisesRegex(ValueError, "does not support custom weights"):
+                    QuickAdapterRegressorV3._validate_metric_weights_support(
+                        metric, ctx="label_distance_metric", mode="raise"
+                    )
+                with self.assertLogs(REGRESSOR_LOGGER, level="WARNING"):
+                    self.assertIsNone(
+                        QuickAdapterRegressorV3._validate_metric_weights_support(
+                            metric, ctx="label_distance_metric", mode="warn"
+                        )
+                    )
+                self.assertIsNone(
+                    QuickAdapterRegressorV3._validate_metric_weights_support(
+                        metric, ctx="label_distance_metric", mode="none"
+                    )
+                )
+
+    def test_metric_weights_support_passes_a_weight_capable_metric_through(self):
+        weight_capable = QuickAdapterRegressorV3._SCIPY_METRICS_SET - {
+            "seuclidean",
+            "mahalanobis",
+            "jensenshannon",
+        }
+        for metric in weight_capable:
+            with self.subTest(metric=metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._validate_metric_weights_support(
+                        metric, ctx="label_distance_metric", mode="raise"
+                    ),
+                    metric,
+                )
+
+    def test_prepare_distance_kwargs_carries_weights_only_for_a_weight_capable_metric(self):
+        weights = np.array([0.25, 0.75])
+        with self.assertLogs(REGRESSOR_LOGGER, level="WARNING"):
+            refused = QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "mahalanobis",
+                weights,
+                mode="warn",
+                reference_matrix=REFERENCE_MATRIX,
+            )
+        self.assertNotIn("w", refused)
+        accepted = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "euclidean", weights, reference_matrix=REFERENCE_MATRIX
+        )
+        np.testing.assert_array_equal(accepted["w"], weights)
+        self.assertEqual(
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "euclidean", reference_matrix=REFERENCE_MATRIX
+            ),
+            {},
+        )
+
+    def test_prepare_distance_kwargs_builds_the_seuclidean_variance_vector(self):
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "seuclidean", reference_matrix=REFERENCE_MATRIX
+        )
+        self.assertEqual(sorted(kwargs), ["V"])
+        np.testing.assert_allclose(
+            kwargs["V"],
+            np.var(REFERENCE_MATRIX, axis=0, ddof=1),
+            rtol=1e-12,
+            atol=0.0,
+        )
+
+    def test_prepare_distance_kwargs_builds_the_mahalanobis_inverse_covariance(self):
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "mahalanobis", reference_matrix=REFERENCE_MATRIX
+        )
+        self.assertEqual(sorted(kwargs), ["VI"])
+        np.testing.assert_allclose(
+            kwargs["VI"], floored_pseudo_inverse(REFERENCE_MATRIX), rtol=1e-12, atol=0.0
+        )
+        np.testing.assert_allclose(kwargs["VI"], kwargs["VI"].T, rtol=1e-12, atol=0.0)
+
+    def test_prepare_distance_kwargs_floors_a_collinear_eigenvalue_instead_of_inverting_it(self):
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "mahalanobis", reference_matrix=COLLINEAR_MATRIX
+        )
+        np.testing.assert_allclose(
+            kwargs["VI"], floored_pseudo_inverse(COLLINEAR_MATRIX), rtol=1e-12, atol=0.0
+        )
+        self.assertTrue(np.all(np.isfinite(kwargs["VI"])))
+        np.testing.assert_allclose(kwargs["VI"], kwargs["VI"].T, rtol=1e-12, atol=0.0)
+        # A plain pseudo-inverse would drop the degenerate direction instead of flooring it.
+        self.assertFalse(
+            np.allclose(
+                kwargs["VI"], np.linalg.pinv(np.cov(COLLINEAR_MATRIX, rowvar=False, ddof=1))
+            )
+        )
+
+    def test_prepare_distance_kwargs_refuses_a_degenerate_reference_front(self):
+        cases = {
+            "single_row": (
+                REFERENCE_MATRIX[:1],
+                "finite full front with at least two rows",
+            ),
+            "non_finite": (
+                np.array([[0.0, 0.0], [1.0, 2.0], [np.nan, 1.0], [3.0, 4.0]]),
+                "finite full front with at least two rows",
+            ),
+            "constant_objective": (
+                np.array([[0.0, 1.0], [1.0, 1.0], [2.0, 1.0], [3.0, 1.0]]),
+                "positive active-objective variances",
+            ),
+        }
+        for name, (matrix, fragment) in cases.items():
+            for metric in ("seuclidean", "mahalanobis"):
+                with (
+                    self.subTest(case=name, metric=metric),
+                    self.assertRaisesRegex(ValueError, fragment),
+                ):
+                    QuickAdapterRegressorV3._prepare_distance_kwargs(
+                        metric, reference_matrix=matrix
+                    )
+
+    def test_prepare_distance_kwargs_validates_the_minkowski_power_only_for_minkowski(self):
+        self.assertEqual(
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", p=3.0, reference_matrix=REFERENCE_MATRIX
+            ),
+            {"p": 3.0},
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", reference_matrix=REFERENCE_MATRIX
+            ),
+            {},
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "euclidean", p=3.0, reference_matrix=REFERENCE_MATRIX
+            ),
+            {},
+        )
+        with self.assertRaisesRegex(ValueError, "must be > 0"):
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", p=-1.0, mode="raise", reference_matrix=REFERENCE_MATRIX
+            )
+
+    def test_selection_metadata_is_json_native_and_defaults_to_the_distance_category(self):
+        metadata = label_regressor()._optuna_label_selection_metadata()
+        self.assertEqual(metadata["schema_version"], _OPTUNA_LABEL_SELECTION_SCHEMA_VERSION)
+        self.assertIsNone(metadata["label_weights"])
+        self.assertIsNone(metadata["label_p_order"])
+        self.assertEqual(
+            metadata["method_config"],
+            {
+                "category": "distance",
+                "method": "compromise_programming",
+                "distance_metric": "euclidean",
+            },
+        )
+        self.assertIsInstance(json_roundtrip(metadata), dict)
+
+    def test_selection_metadata_coerces_numpy_weights_and_p_order_to_plain_floats(self):
+        metadata = label_regressor(
+            ft_params={
+                "label_method": "kmeans",
+                "label_weights": np.array([0.25, 0.75]),
+                "label_p_order": np.float64(3.0),
+            }
+        )._optuna_label_selection_metadata()
+        self.assertEqual(metadata["label_weights"], [0.25, 0.75])
+        self.assertIs(type(metadata["label_weights"][0]), float)
+        self.assertIs(type(metadata["label_p_order"]), float)
+        self.assertEqual(metadata["label_p_order"], 3.0)
+        self.assertEqual(metadata["method_config"]["category"], "cluster")
+        self.assertIsInstance(json_roundtrip(metadata), dict)
+
+    def test_selection_metadata_refuses_non_finite_weights_and_p_order(self):
+        cases = {
+            "label_weights": (
+                {"label_weights": [0.5, np.inf]},
+                "label_weights contains non-finite",
+            ),
+            "label_p_order": (
+                {"label_p_order": float("nan")},
+                "label_p_order is non-finite",
+            ),
+        }
+        for name, (ft_params, fragment) in cases.items():
+            with self.subTest(field=name), self.assertRaisesRegex(ValueError, fragment):
+                label_regressor(ft_params=ft_params)._optuna_label_selection_metadata()
+
+    def test_optuna_create_sampler_maps_every_enum_to_its_sampler(self):
+        model = label_regressor(_optuna_config=optuna_config())
+        for sampler, expected_type in (
+            ("tpe", "TPESampler"),
+            ("nsgaii", "NSGAIISampler"),
+            ("nsgaiii", "NSGAIIISampler"),
+        ):
+            with self.subTest(sampler=sampler):
+                self.assertEqual(type(model.optuna_create_sampler(sampler)).__name__, expected_type)
+
+    def test_optuna_create_sampler_defaults_to_the_configured_sampler(self):
+        model = label_regressor(_optuna_config=optuna_config())
+        self.assertEqual(type(model.optuna_create_sampler()).__name__, "TPESampler")
+        self.assertEqual(type(model.optuna_create_sampler(None)).__name__, "TPESampler")
+
+    def test_optuna_create_sampler_reads_the_tpe_startup_trials_and_parallel_constant_liar(self):
+        single = label_regressor(_optuna_config=optuna_config(n_startup_trials=9, n_jobs=1))
+        parallel = label_regressor(_optuna_config=optuna_config(n_startup_trials=9, n_jobs=4))
+        self.assertEqual(single.optuna_create_sampler("tpe")._n_startup_trials, 9)
+        self.assertFalse(single.optuna_create_sampler("tpe")._constant_liar)
+        self.assertTrue(parallel.optuna_create_sampler("tpe")._constant_liar)
+
+    def test_optuna_create_sampler_resolves_auto_through_the_patched_optunahub(self):
+        model = label_regressor(_optuna_config=optuna_config(seed=42))
+        with mock.patch.object(
+            regressor_module.optunahub, "load_module", return_value=mock.MagicMock()
+        ) as load_module:
+            model.optuna_create_sampler("auto")
+        load_module.assert_called_once_with("samplers/auto_sampler")
+
+    def test_optuna_create_sampler_reports_a_missing_sampler_through_the_enum_message(self):
+        model = label_regressor(_optuna_config=optuna_config(sampler=None))
+        with self.assertRaises(ValueError) as caught:
+            model.optuna_create_sampler(None)
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message(
+                "optuna sampler",
+                None,
+                QuickAdapterRegressorV3._OPTUNA_SAMPLERS,
+            ),
+        )
+
+    def test_optuna_create_sampler_refuses_an_unmapped_sampler_by_exhaustiveness(self):
+        model = label_regressor(_optuna_config=optuna_config())
+        with self.assertRaises(AssertionError):
+            model.optuna_create_sampler("cmaes")
+
+    def test_optuna_create_pruner_follows_the_single_objective_flag(self):
+        model = label_regressor(_optuna_config=optuna_config(min_resource=9))
+        for flag in (True, 1):
+            with self.subTest(flag=flag):
+                pruner = model.optuna_create_pruner(flag)
+                self.assertEqual(type(pruner).__name__, "HyperbandPruner")
+                self.assertEqual(pruner._min_resource, 9)
+        for flag in (False, 0):
+            with self.subTest(flag=flag):
+                self.assertEqual(type(model.optuna_create_pruner(flag)).__name__, "NopPruner")
+
+    def test_optuna_samplers_by_namespace_keeps_the_two_namespaces_apart(self):
+        model = label_regressor(
+            _optuna_config=optuna_config(sampler="nsgaii", label_sampler="nsgaiii")
+        )
+        hp_allowed, hp_sampler = model.optuna_samplers_by_namespace(_OPTUNA_NAMESPACES.hp)
+        label_allowed, label_sampler = model.optuna_samplers_by_namespace(_OPTUNA_NAMESPACES.label)
+        self.assertEqual(hp_sampler, "nsgaii")
+        self.assertEqual(label_sampler, "nsgaiii")
+        self.assertEqual(hp_allowed, frozenset({"tpe", "auto"}))
+        self.assertEqual(label_allowed, frozenset({"tpe", "auto", "nsgaii", "nsgaiii"}))
+        self.assertTrue(hp_allowed < label_allowed)
+
+    def test_optuna_samplers_by_namespace_refuses_an_unknown_namespace(self):
+        model = label_regressor(_optuna_config=optuna_config())
+        with self.assertRaises(ValueError) as caught:
+            model.optuna_samplers_by_namespace("hp_label")
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("namespace", "hp_label", _OPTUNA_NAMESPACES),
+        )
+
+    def test_candle_pool_full_is_centred_on_the_label_frequency(self):
+        for frequency, expected in (
+            (2, [1, 2, 3]),
+            (3, [2, 3, 4]),
+            (4, [2, 3, 4, 5, 6]),
+            (6, [3, 4, 5, 6, 7, 8, 9]),
+            (9, [5, 6, 7, 8, 9, 10, 11, 12, 13]),
+        ):
+            with self.subTest(frequency=frequency):
+                model = candle_regressor([PAIR], frequency, seed=1)
+                self.assertEqual(model._optuna_label_candle_pool_full, expected)
+                self.assertEqual(model._optuna_label_candle_pool_full_cache[frequency], expected)
+
+    def test_init_candle_pool_preserves_the_full_pool_and_is_seed_reproducible(self):
+        pairs = [PAIR, "ETH/USDT"]
+        first = candle_regressor(pairs, 8, seed=3)
+        second = candle_regressor(pairs, 8, seed=3)
+        other_seed = candle_regressor(pairs, 8, seed=4)
+        first.init_optuna_label_candle_pool()
+        second.init_optuna_label_candle_pool()
+        other_seed.init_optuna_label_candle_pool()
+        self.assertEqual(first._optuna_label_candle_pool, second._optuna_label_candle_pool)
+        self.assertNotEqual(first._optuna_label_candle_pool, other_seed._optuna_label_candle_pool)
+        self.assertEqual(
+            sorted(first._optuna_label_candle_pool),
+            sorted(first._optuna_label_candle_pool_full),
+        )
+        # The cache backs the property, so re-initialising must not have consumed it.
+        self.assertEqual(
+            first._optuna_label_candle_pool_full_cache[8],
+            first._optuna_label_candle_pool_full,
+        )
+
+    def test_candle_pool_fires_once_per_pair_and_rearms_after_the_budget(self):
+        pairs = [PAIR, "ETH/USDT", "SOL/USDT"]
+        model = candle_regressor(pairs, 6, seed=11)
+        full = set(model._optuna_label_candle_pool_full)
+        model.init_optuna_label_candle_pool()
+
+        for pair in pairs:
+            model.set_optuna_label_candle(pair)
+            self.assertIn(model._optuna_label_candle[pair], full)
+
+        self.assertEqual(len(model._optuna_label_candle), len(pairs))
+        self.assertEqual(
+            len(set(model._optuna_label_candle.values())),
+            len(pairs),
+            "each pair must hold a distinct candle while the pool still has the budget",
+        )
+        self.assertEqual(len(model._optuna_label_candle_pool), len(full) - len(pairs))
+
+        # A further call for an already-assigned pair releases its old candle back
+        # into the pool rather than drawing a second one for that pair.
+        released = model._optuna_label_candle[pairs[0]]
+        model.set_optuna_label_candle(pairs[0])
+        self.assertEqual(len(model._optuna_label_candle), len(pairs))
+        self.assertEqual(len(model._optuna_label_candle_pool), len(full) - len(pairs))
+        self.assertEqual(
+            set(model._optuna_label_candle_pool),
+            full - set(model._optuna_label_candle.values()),
+        )
+        self.assertIn(released, model._optuna_label_candle_pool)
+
+    def test_candle_pool_draws_the_last_available_candle(self):
+        model = candle_regressor([PAIR], 4, seed=1)
+        model._optuna_label_candle_pool_full_cache = {4: [1, 2, 3]}
+        model._optuna_label_candle_pool = [1, 2, 3]
+        model.set_optuna_label_candle(PAIR)
+        self.assertEqual(model._optuna_label_candle[PAIR], 3)
+        self.assertEqual(sorted(model._optuna_label_candle_pool), [1, 2])
+
+    def test_candle_pool_reserves_a_candle_another_pair_still_needs(self):
+        pairs = [PAIR, "ETH/USDT"]
+        model = candle_regressor(pairs, 4, seed=1)
+        model._optuna_label_candle_pool_full_cache = {4: [1, 2, 3]}
+        # The first pair has taken candle 3 and burned one candle against it, so it
+        # still needs 2. The second pair must not be dealt the same value.
+        model._optuna_label_candle[PAIR] = 3
+        model._optuna_label_candles[PAIR] = 1
+        model._optuna_label_candle_pool = [1, 2]
+        model.set_optuna_label_candle("ETH/USDT")
+        self.assertEqual(model._optuna_label_candle["ETH/USDT"], 1)
+        self.assertEqual(sorted(model._optuna_label_candle_pool), [2])
+
+    def test_candle_pool_reshuffles_after_refilling_available_candles(self):
+        model = candle_regressor([PAIR], 6, seed=1)
+        model._optuna_label_candle_pool_full_cache = {6: [3, 4, 5, 6, 7, 8, 9]}
+        # Candle 3 is spent and 9 is unclaimed, so the refill puts 9 back and
+        # reshuffles: the pool must not come back in sorted order.
+        model._optuna_label_candle_pool = [3, 4, 5, 6, 7, 8]
+        model._optuna_label_candle = {PAIR: 3}
+        model.set_optuna_label_candle(PAIR)
+        self.assertEqual(model._optuna_label_candle[PAIR], 8)
+        self.assertEqual(sorted(model._optuna_label_candle_pool), [3, 4, 5, 6, 7, 9])
+        self.assertNotEqual(
+            model._optuna_label_candle_pool,
+            sorted(model._optuna_label_candle_pool),
+            "a refilled pool must be reshuffled, not left in sorted order",
+        )
+
+    def test_candle_pool_rearms_after_being_drained(self):
+        model = candle_regressor([PAIR], 2, seed=1)
+        full = model._optuna_label_candle_pool_full
+        self.assertEqual(full, [1, 2, 3])
+        model.init_optuna_label_candle_pool()
+        for _ in range(len(full)):
+            model.set_optuna_label_candle(PAIR)
+            self.assertIn(model._optuna_label_candle[PAIR], full)
+
+        model._optuna_label_candle_pool = []
+        with self.assertLogs(REGRESSOR_LOGGER, level="WARNING") as logs:
+            model.set_optuna_label_candle(PAIR)
+        self.assertIn("pool is empty, reinitializing", "\n".join(logs.output))
+        self.assertEqual(len(model._optuna_label_candle_pool), len(full) - 1)
+        self.assertEqual(
+            set(model._optuna_label_candle_pool) | {model._optuna_label_candle[PAIR]},
+            set(full),
+        )
+
+    def test_init_candle_pool_refuses_a_pool_emptied_by_shuffle(self):
+        model = candle_regressor([PAIR], 4, seed=1)
+        model._optuna_label_shuffle_rng = mock.MagicMock()
+        model._optuna_label_shuffle_rng.shuffle.side_effect = lambda pool: pool.clear()
+        with self.assertRaisesRegex(RuntimeError, "pool became empty after shuffle"):
+            model.init_optuna_label_candle_pool()
+
+    def test_init_candle_pool_refuses_an_empty_initial_pool(self):
+        model = candle_regressor([PAIR], 4, seed=1)
+        model._optuna_label_candle_pool_full_cache = {4: []}
+        with self.assertRaisesRegex(RuntimeError, "initial pool is empty"):
+            model.init_optuna_label_candle_pool()
+
+    def test_construction_arms_the_pool_without_tripping_the_rearm_warning(self):
+        # __init__ initialises the pool itself; only set_optuna_label_candle re-arms
+        # a drained one, so a clean construction must log no re-arm warning.
+        with (
+            temporary_directory() as temp,
+            mock.patch.object(
+                regressor_module.logger, "warning", wraps=regressor_module.logger.warning
+            ) as warning,
+        ):
+            QuickAdapterRegressorV3(
+                config=model_config(
+                    temp,
+                    freqai={
+                        "data_split_parameters": {
+                            "test_size": 0.2,
+                            "shuffle": False,
+                        }
+                    },
+                )
+            )
+        self.assertNotIn(
+            mock.call(f"[{PAIR}] Optuna label candle pool is empty, reinitializing"),
+            warning.mock_calls,
+        )
+
+    def test_min_max_pred_rounds_the_threshold_window_to_label_period_multiples(self):
+        model = label_regressor(ft_params={"label_period_candles": 4})
+        model._label_defaults = (18, 10.5)
+        predictions = pd.DataFrame({"&s-extrema": [float(i) for i in range(100)]})
+        cases = {
+            (5, 3): 6,
+            (5, 1): 5,
+            (7, 4): 8,
+            (5, 2): 4,
+            (100, 7): 98,
+        }
+        for (fit_candles, period), window in cases.items():
+            with self.subTest(fit_candles=fit_candles, label_period_candles=period):
+                minimum, maximum = model.min_max_pred(
+                    "&s-extrema", min_max_config(), predictions, fit_candles, period
+                )
+                self.assertEqual(maximum, 99.0)
+                self.assertEqual(minimum, float(len(predictions) - window))
+
+    def test_min_max_pred_falls_back_to_the_configured_label_period(self):
+        model = label_regressor(ft_params={"label_period_candles": 4})
+        model._label_defaults = (18, 10.5)
+        predictions = pd.DataFrame({"&s-extrema": [float(i) for i in range(100)]})
+        for unusable in (None, 0, -2):
+            with self.subTest(label_period_candles=unusable):
+                self.assertEqual(
+                    model.min_max_pred("&s-extrema", min_max_config(), predictions, 5, unusable),
+                    (92.0, 99.0),
+                )
+
+    def test_min_max_pred_returns_the_no_entry_signal_pair_unchanged(self):
+        model = label_regressor(ft_params={"label_period_candles": 4})
+        model._label_defaults = (18, 10.5)
+        predictions = pd.DataFrame({"&s-extrema": [float(i) for i in range(100)]})
+        self.assertEqual(
+            model.min_max_pred(
+                "&s-extrema",
+                min_max_config(threshold_method="rolling_quantile"),
+                predictions,
+                5,
+                3,
+            ),
+            NO_ENTRY_SIGNALS,
+        )
+        self.assertEqual(
+            model.min_max_pred("&s-other", min_max_config(), predictions, 5, 3),
+            NO_ENTRY_SIGNALS,
+        )
+
+    def test_min_max_pred_leaves_the_method_none_gate_to_its_caller(self):
+        # ``fit_live_predictions`` skips a ``method="none"`` column before it ever
+        # calls min_max_pred, so min_max_pred itself is indifferent to the method
+        # key: it dispatches on threshold_method alone.
+        model = label_regressor(ft_params={"label_period_candles": 4})
+        model._label_defaults = (18, 10.5)
+        predictions = pd.DataFrame({"&s-extrema": [float(i) for i in range(100)]})
+        self.assertEqual(
+            model.min_max_pred("&s-extrema", min_max_config(), predictions, 5, 3),
+            model.min_max_pred(
+                "&s-extrema",
+                min_max_config(method=PREDICTION_METHODS[0]),
+                predictions,
+                5,
+                3,
+            ),
+        )
+
+    def test_min_max_pred_dispatches_every_supported_threshold_method(self):
+        model = label_regressor(ft_params={"label_period_candles": 4})
+        model._label_defaults = (18, 10.5)
+        values = [float(i) * 0.5 for i in range(40)]
+        predictions = pd.DataFrame({"&s-extrema": values})
+        for threshold_method in (*SKIMAGE_THRESHOLD_METHODS, *CUSTOM_THRESHOLD_METHODS):
+            with self.subTest(threshold_method=threshold_method):
+                minimum, maximum = model.min_max_pred(
+                    "&s-extrema",
+                    min_max_config(threshold_method=threshold_method),
+                    predictions,
+                    20,
+                    2,
+                )
+                self.assertLess(minimum, maximum)
+                self.assertGreaterEqual(maximum, max(values[-20:]))
+
+    def test_strategy_optuna_load_best_params_tolerates_selection_metadata_drift(self):
+        strategy = object.__new__(QuickAdapterV3)
+        strategy.models_full_path = Path("/nonexistent/models")
+        strategy.pairs = [PAIR]
+        with mock.patch.object(
+            strategy_module, "optuna_load_best_params", return_value=None
+        ) as loader:
+            strategy.optuna_load_best_params(PAIR, _OPTUNA_NAMESPACES.label)
+        loader.assert_called_once()
+        arguments, keywords = loader.call_args
+        self.assertEqual(arguments[1:3], (PAIR, _OPTUNA_NAMESPACES.label))
+        self.assertEqual(keywords, {"pairs": [PAIR]})
+        self.assertNotIn("expected_selection_metadata", keywords)
+        self.assertNotIn("expected_objective_identity", keywords)
+
+    def test_regressor_optuna_load_best_params_pins_the_expected_selection_metadata(self):
+        model = label_regressor(full_path=Path("/nonexistent/models"), pairs=[PAIR])
+        with mock.patch.object(
+            regressor_module, "optuna_load_best_params", return_value=None
+        ) as loader:
+            model.optuna_load_best_params(PAIR, _OPTUNA_NAMESPACES.label)
+            _, keywords = loader.call_args
+            self.assertEqual(
+                keywords["expected_selection_metadata"],
+                model._optuna_label_selection_metadata(),
+            )
+            self.assertIsNone(keywords["expected_objective_identity"])
+            model.optuna_load_best_params(PAIR, _OPTUNA_NAMESPACES.hp)
+            _, keywords = loader.call_args
+            self.assertIsNone(keywords["expected_selection_metadata"])
+            self.assertEqual(
+                keywords["expected_objective_identity"],
+                QuickAdapterRegressorV3._OPTUNA_HP_OBJECTIVE_IDENTITY,
+            )
+
+    def test_construction_from_the_shared_config_pins_the_config_boundary(self):
+        with temporary_directory() as temp:
+            model = QuickAdapterRegressorV3(config=model_config(temp))
+        self.assertEqual(model.pairs, [PAIR])
+        self.assertEqual(model._optuna_config["sampler"], "tpe")
+        self.assertEqual(model._optuna_config["label_sampler"], "tpe")
+        # test_size == 0 disables HPO, so the candle pool is never armed at construction.
+        self.assertFalse(model._optuna_hyperopt)
+        self.assertEqual(model._optuna_label_candle_pool, [])
+        self.assertEqual(model._optuna_label_candle_pool_full, [1, 2, 3])
+        self.assertEqual(model._optuna_label_candle, {})
+        self.assertEqual(model._optuna_label_candles, {PAIR: 0})
+        self.assertEqual(model._optuna_label_incremented_pairs, [])
+        self.assertEqual(
+            model._optuna_label_selection_metadata()["method_config"]["method"],
+            QuickAdapterRegressorV3.LABEL_METHOD_DEFAULT,
+        )
+
+    def test_construction_arms_the_candle_pool_when_hyperopt_is_enabled(self):
+        with temporary_directory() as temp:
+            model = QuickAdapterRegressorV3(
+                config=model_config(
+                    temp,
+                    freqai={"data_split_parameters": {"test_size": 0.2, "shuffle": False}},
+                )
+            )
+        self.assertTrue(model._optuna_hyperopt)
+        # The one pair is dealt its candle during __init__, so the pool is short by one.
+        self.assertEqual(model._optuna_label_candle_pool_full, [1, 2, 3])
+        self.assertEqual(sorted(model._optuna_label_candle), [PAIR])
+        self.assertIn(model._optuna_label_candle[PAIR], model._optuna_label_candle_pool_full)
+        self.assertEqual(
+            sorted(model._optuna_label_candle_pool),
+            sorted(set(model._optuna_label_candle_pool_full) - {model._optuna_label_candle[PAIR]}),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_regressor_optuna_study.py b/quickadapter/tests/test_regressor_optuna_study.py
new file mode 100644 (file)
index 0000000..f50a851
--- /dev/null
@@ -0,0 +1,1067 @@
+"""Optuna study lifecycle — create / optimize / validate / enqueue / load / delete / has-best; requires the Freqtrade QA image."""
+
+import datetime
+import json
+import unittest
+from collections.abc import Callable
+from pathlib import Path
+from unittest import mock
+
+import numpy as np
+import optuna
+from EnumErrors import enum_error_message
+from optuna.distributions import FloatDistribution, IntDistribution
+from optuna.trial import FrozenTrial, TrialState
+from qa_support import PAIR, QaTestCase, model_config, temporary_directory
+
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import (
+    QuickAdapterRegressorV3,
+)
+
+REGRESSOR = QuickAdapterRegressorV3
+LABEL_DIRECTIONS = list(REGRESSOR._OPTUNA_LABEL_DIRECTIONS)
+HP_IDENTITY = REGRESSOR._OPTUNA_HP_OBJECTIVE_IDENTITY
+LOGGER = "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3"
+ALPHA = FloatDistribution(0.0, 1.0)
+LABEL_CANDLES = IntDistribution(1, 20)
+EPOCH = datetime.datetime(2024, 1, 1)
+LABEL_PARAMS = {"label_period_candles": 3, "label_natr_multiplier": 1.25}
+
+
+def regressor(
+    tmp_path: Path, *, live: bool = False, **optuna_hyperopt: object
+) -> QuickAdapterRegressorV3:
+    """Build a regressor without running __init__, injecting only what the study lifecycle reads.
+
+    ``model_config`` pins both samplers to ``tpe``; ``optuna_hyperopt`` overrides are
+    deep-merged on top, so no test can reach ``optunahub``'s outbound ``auto`` fetch.
+    """
+    config = model_config(tmp_path, freqai={"optuna_hyperopt": optuna_hyperopt})
+    model = object.__new__(REGRESSOR)
+    model.config = config
+    model.freqai_info = config["freqai"]
+    model.ft_params = {"label_method": REGRESSOR.LABEL_METHOD_DEFAULT}
+    model.live = live
+    model.pairs = [PAIR]
+    # set_full_path's own arithmetic; the directories optuna_create_storage needs must exist.
+    model.full_path = Path(config["user_data_dir"]) / "models" / config["freqai"]["identifier"]
+    model.full_path.mkdir(parents=True, exist_ok=True)
+    model._optuna_hp_value = {PAIR: -1.0}
+    model._optuna_hp_params = {PAIR: {}}
+    model._optuna_label_values = {PAIR: [-1.0] * REGRESSOR._OPTUNA_LABEL_N_OBJECTIVES}
+    model._optuna_label_params = {PAIR: {"label_period_candles": 1, "label_natr_multiplier": 1.0}}
+    return model
+
+
+def frozen(
+    number: int = 0,
+    *,
+    state: TrialState = TrialState.COMPLETE,
+    value: float | None = None,
+    values: list[float] | None = None,
+    params: dict | None = None,
+    distributions: dict | None = None,
+) -> FrozenTrial:
+    """Build a FrozenTrial for optuna 5.0, whose __init__ requires all eleven positionals.
+
+    ``trial_id`` is the eleventh and is required, so it is passed explicitly; ``values`` is
+    keyword-only. ``datetime_complete`` is set only for a finished state, which is what
+    optuna's own validator demands.
+    """
+    return FrozenTrial(
+        number,
+        state,
+        value,
+        EPOCH,
+        EPOCH if state.is_finished() else None,
+        params or {},
+        distributions or {},
+        {},
+        {},
+        {},
+        number,
+        values=values,
+    )
+
+
+def trial_numbers(study: optuna.study.Study) -> list[int]:
+    return [trial.number for trial in study.get_trials(deepcopy=False)]
+
+
+def waiting_params(study: optuna.study.Study) -> list[dict]:
+    return [
+        trial.system_attrs["fixed_params"]
+        for trial in study.get_trials(deepcopy=False, states=(TrialState.WAITING,))
+    ]
+
+
+def tree(root: Path) -> list[str]:
+    return sorted(str(path.relative_to(root)) for path in root.rglob("*"))
+
+
+def study_name(model: QuickAdapterRegressorV3, pair: str, namespace: str) -> str:
+    return f"{model.freqai_info['identifier']}-{pair}-{namespace}"
+
+
+def best_params_path(model: QuickAdapterRegressorV3, namespace: str) -> Path:
+    return model.full_path / f"optuna-{namespace}-best-params-BTC_USDT.json"
+
+
+def rewrite_payload(path: Path, mutate: Callable[[dict], None]) -> None:
+    """Apply ``mutate`` to the on-disk best-params payload and write it back."""
+    payload = json.loads(path.read_text(encoding="utf-8"))
+    mutate(payload)
+    path.write_text(json.dumps(payload), encoding="utf-8")
+
+
+def assert_logged(test: unittest.TestCase, records: list[str], fragment: str) -> None:
+    """Assert some captured record contains ``fragment``, not necessarily the first.
+
+    A single ``optuna_create_study`` call can emit more than one warning (the marker
+    inspection warning plus the drift warning), so pinning an index would make these
+    assertions depend on emission order rather than on content.
+    """
+    test.assertTrue(
+        any(fragment in record for record in records), f"{fragment!r} absent from {records}"
+    )
+
+
+def single_study(model: QuickAdapterRegressorV3, pair: str = PAIR) -> optuna.study.Study:
+    return model.optuna_create_study(pair, "hp", direction=optuna.study.StudyDirection.MAXIMIZE)
+
+
+def multi_study(model: QuickAdapterRegressorV3, pair: str = PAIR) -> optuna.study.Study:
+    return model.optuna_create_study(pair, "label", directions=LABEL_DIRECTIONS)
+
+
+def seed_trial(
+    model: QuickAdapterRegressorV3, study: optuna.study.Study, *, legacy_identity: bool = False
+) -> optuna.study.Study:
+    """Add one completed trial to ``study``, stamping a drifted marker first if asked.
+
+    The trial shape follows the study's own arity: optuna refuses a single-objective
+    FrozenTrial on a multi-objective study and vice versa.
+    """
+    target = study
+    if legacy_identity:
+        target = optuna.load_study(
+            study_name=study.study_name, storage=model.optuna_create_storage(PAIR)
+        )
+        target.set_user_attr("objective_identity", "legacy-identity-v0")
+    if len(study.directions) > 1:
+        study.add_trial(
+            frozen(
+                0,
+                values=[0.5] * REGRESSOR._OPTUNA_LABEL_N_OBJECTIVES,
+                params={"label_period_candles": 3},
+                distributions={"label_period_candles": LABEL_CANDLES},
+            )
+        )
+    else:
+        study.add_trial(frozen(0, value=0.5, params={"alpha": 0.5}, distributions={"alpha": ALPHA}))
+    return target
+
+
+class RegressorOptunaStudyTest(QaTestCase):
+    # ------------------------------------------------------------------ direction guards
+
+    def test_create_refuses_both_a_direction_and_directions(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            with self.assertRaisesRegex(ValueError, "Cannot specify both"):
+                model.optuna_create_study(
+                    PAIR,
+                    "hp",
+                    direction=optuna.study.StudyDirection.MAXIMIZE,
+                    directions=LABEL_DIRECTIONS,
+                )
+
+    def test_create_needs_either_one_direction_or_at_least_two(self):
+        single = optuna.study.StudyDirection.MAXIMIZE
+        for kwargs in ({}, {"directions": None}, {"directions": []}, {"directions": [single]}):
+            with (
+                self.subTest(kwargs=sorted(kwargs), value=kwargs.get("directions")),
+                temporary_directory() as tmp,
+            ):
+                model = regressor(tmp)
+                with self.assertRaisesRegex(ValueError, "at least 2 objectives"):
+                    model.optuna_create_study(PAIR, "hp", **kwargs)
+
+    def test_two_directions_yield_a_two_objective_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            multi = model.optuna_create_study(
+                PAIR,
+                "label",
+                directions=[optuna.study.StudyDirection.MAXIMIZE] * 2,
+            )
+            self.assertEqual(len(multi.directions), 2)
+
+    def test_a_bare_direction_in_place_of_a_list_is_a_type_error_not_a_creation(self):
+        # optuna_create_study probes len(directions) unguarded once `direction` is None,
+        # so a non-list slips past both of its own checks and fails at the length probe.
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            with self.assertRaises(TypeError):
+                model.optuna_create_study(
+                    PAIR, "hp", directions=optuna.study.StudyDirection.MAXIMIZE
+                )
+
+    def test_optimize_repeats_the_both_specified_guard(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            with self.assertRaisesRegex(ValueError, "Cannot specify both"):
+                model.optuna_optimize(
+                    PAIR,
+                    "hp",
+                    lambda trial: 1.0,
+                    direction=optuna.study.StudyDirection.MAXIMIZE,
+                    directions=LABEL_DIRECTIONS,
+                )
+
+    def test_optimize_refuses_a_one_element_list_before_creating_any_study(self):
+        # optuna_optimize's guard is `isinstance(directions, list) and len(directions) < 2`,
+        # so it refuses a one-element list itself, without reaching optuna_create_study.
+        with temporary_directory() as tmp:
+            model = regressor(tmp, n_trials=1, n_jobs=1, timeout=60)
+            model.optuna_create_study = mock.Mock(return_value=None)
+            with self.assertRaisesRegex(ValueError, "at least 2 objectives"):
+                model.optuna_optimize(
+                    PAIR,
+                    "hp",
+                    lambda trial: 1.0,
+                    directions=[optuna.study.StudyDirection.MAXIMIZE],
+                )
+            model.optuna_create_study.assert_not_called()
+
+    def test_optimize_hands_a_missing_directions_to_optuna_create_study(self):
+        # The asymmetry: `directions=None` is not a list, so optuna_optimize's own guard
+        # passes it down, where optuna_create_study's `directions is None` branch refuses it.
+        for directions in (None, ()):
+            with self.subTest(directions=directions), temporary_directory() as tmp:
+                model = regressor(tmp, n_trials=1, n_jobs=1, timeout=60)
+                with self.assertRaisesRegex(ValueError, "at least 2 objectives"):
+                    model.optuna_optimize(PAIR, "hp", lambda trial: 1.0, directions=directions)
+
+    # ------------------------------------------------------- reset semantics: non-live
+
+    def test_a_non_live_study_is_deleted_and_recreated_on_every_creation(self):
+        # `continuous` is forced true while not live, so even a populated study is dropped.
+        for continuous in (True, False):
+            with self.subTest(continuous=continuous), temporary_directory() as tmp:
+                model = regressor(tmp, live=False, continuous=continuous)
+                shared = optuna.storages.InMemoryStorage()
+                model.optuna_create_storage = lambda pair, backend=shared: backend
+                first = single_study(model)
+                seed_trial(model, first)
+                self.assertEqual(trial_numbers(first), [0])
+
+                second = single_study(model)
+                self.assertIsNotNone(second)
+                self.assertNotEqual(second._study_id, first._study_id)
+                self.assertEqual(trial_numbers(second), [])
+
+    def test_a_live_study_with_continuous_configured_true_is_always_recreated(self):
+        for namespace in ("hp", "label"):
+            with self.subTest(namespace=namespace), temporary_directory() as tmp:
+                model = regressor(tmp, live=True, continuous=True)
+                study = single_study(model) if namespace == "hp" else multi_study(model)
+                seed_trial(model, study)
+                again = single_study(model) if namespace == "hp" else multi_study(model)
+                self.assertNotEqual(again._study_id, study._study_id)
+                self.assertEqual(trial_numbers(again), [])
+
+    def test_a_live_label_study_is_recreated_even_when_the_policy_forbids_reset(self):
+        with temporary_directory() as tmp:
+            model = regressor(
+                tmp, live=True, continuous=True, reset_label_study_on_schema_mismatch=False
+            )
+            first = multi_study(model)
+            seed_trial(model, first)
+            second = multi_study(model)
+            self.assertNotEqual(second._study_id, first._study_id)
+            self.assertEqual(trial_numbers(second), [])
+
+    # --------------------------------------------------------- reset semantics: live hp
+
+    def test_a_live_hp_study_survives_a_matching_objective_identity(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            first = single_study(model)
+            self.assertEqual(first.user_attrs, {"objective_identity": HP_IDENTITY})
+            seed_trial(model, first)
+            second = single_study(model)
+            self.assertEqual(second._study_id, first._study_id)
+            self.assertEqual(trial_numbers(second), [0])
+
+    def test_a_live_hp_study_resets_when_the_objective_identity_moves(self):
+        # The hp marker's reset_on_mismatch is unconditional, so the preserve policy —
+        # which only governs the label namespace — cannot keep a stale hp study.
+        for reset_policy in (True, False):
+            with (
+                self.subTest(reset_label_study_on_schema_mismatch=reset_policy),
+                temporary_directory() as tmp,
+            ):
+                model = regressor(
+                    tmp,
+                    live=True,
+                    continuous=False,
+                    reset_label_study_on_schema_mismatch=reset_policy,
+                )
+                single_study(model)
+                legacy = seed_trial(model, single_study(model), legacy_identity=True)
+                self.assertEqual(legacy.user_attrs["objective_identity"], "legacy-identity-v0")
+
+                third = single_study(model)
+                self.assertNotEqual(third._study_id, legacy._study_id)
+                self.assertEqual(trial_numbers(third), [])
+                self.assertEqual(third.user_attrs, {"objective_identity": HP_IDENTITY})
+
+    # ------------------------------------------------------- reset semantics: live label
+
+    def test_a_live_label_study_survives_a_matching_selection_schema(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            first = multi_study(model)
+            self.assertEqual(
+                first.user_attrs["selection_metadata"], model._optuna_label_selection_metadata()
+            )
+            seed_trial(model, first)
+            second = multi_study(model)
+            self.assertEqual(second._study_id, first._study_id)
+            self.assertEqual(trial_numbers(second), [0])
+
+    def test_a_live_label_study_resets_when_the_selection_schema_version_moves(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            first = multi_study(model)
+            drifted = dict(model._optuna_label_selection_metadata())
+            drifted["schema_version"] = drifted["schema_version"] - 1
+            first.set_user_attr("selection_metadata", drifted)
+            seed_trial(model, first)
+
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                second = multi_study(model)
+            assert_logged(self, captured.output, "resetting study")
+            self.assertNotEqual(second._study_id, first._study_id)
+            self.assertEqual(trial_numbers(second), [])
+            self.assertEqual(
+                second.user_attrs["selection_metadata"], model._optuna_label_selection_metadata()
+            )
+
+    def test_a_live_label_study_is_preserved_when_the_policy_forbids_reset(self):
+        with temporary_directory() as tmp:
+            model = regressor(
+                tmp, live=True, continuous=False, reset_label_study_on_schema_mismatch=False
+            )
+            first = multi_study(model)
+            drifted = dict(model._optuna_label_selection_metadata())
+            drifted["schema_version"] = drifted["schema_version"] - 1
+            first.set_user_attr("selection_metadata", drifted)
+            seed_trial(model, first)
+
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                second = multi_study(model)
+            assert_logged(self, captured.output, "preserving study")
+            self.assertEqual(second._study_id, first._study_id)
+            self.assertEqual(trial_numbers(second), [0])
+            # The preserved study must keep the marker it was created with: rewriting it
+            # here is what would make the next run see a compatible schema over trials
+            # selected by the old one.
+            self.assertEqual(second.user_attrs["selection_metadata"], drifted)
+
+    def test_a_live_label_study_keeps_its_trials_when_only_the_method_config_drifted(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            first = multi_study(model)
+            seed_trial(model, first)
+            stored = first.user_attrs["selection_metadata"]
+
+            model.ft_params = {"label_method": "topsis"}
+            current = model._optuna_label_selection_metadata()
+            self.assertNotEqual(stored["method_config"], current["method_config"])
+            self.assertEqual(stored["schema_version"], current["schema_version"])
+
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                second = multi_study(model)
+            assert_logged(self, captured.output, "selection_metadata change detected")
+            self.assertEqual(second._study_id, first._study_id)
+            self.assertEqual(trial_numbers(second), [0])
+            self.assertEqual(second.user_attrs["selection_metadata"], current)
+
+    def test_the_hp_marker_always_resets_while_the_label_marker_follows_the_policy(self):
+        for reset_policy in (True, False):
+            with (
+                self.subTest(reset_label_study_on_schema_mismatch=reset_policy),
+                temporary_directory() as tmp,
+            ):
+                model = regressor(
+                    tmp,
+                    live=True,
+                    continuous=False,
+                    reset_label_study_on_schema_mismatch=reset_policy,
+                )
+                hp = model._optuna_study_marker("hp")
+                self.assertEqual(hp.user_attr_key, "objective_identity")
+                self.assertTrue(hp.reset_on_mismatch)
+                self.assertEqual(hp.build_marker(), HP_IDENTITY)
+                self.assertTrue(hp.is_compatible(HP_IDENTITY))
+                self.assertFalse(hp.is_compatible("legacy-identity-v0"))
+
+                label = model._optuna_study_marker("label")
+                self.assertEqual(label.user_attr_key, "selection_metadata")
+                self.assertEqual(label.reset_on_mismatch, reset_policy)
+                self.assertEqual(label.build_marker(), model._optuna_label_selection_metadata())
+
+    def test_an_unknown_namespace_has_no_study_marker(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            self.assertIsNone(model._optuna_study_marker("candle"))
+
+    # ------------------------------------------------------------- storage selection
+
+    def test_a_live_study_is_backed_by_a_journal_under_the_user_data_dir(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            study = single_study(model)
+            self.assertIsInstance(study._storage, optuna.storages.JournalStorage)
+            journal = model.full_path / "optuna-BTC.log"
+            self.assertTrue(journal.is_file())
+            self.assertEqual(journal.parent, model.full_path)
+            self.assertEqual(model.config["user_data_dir"], tmp)
+            self.assertEqual(
+                [str(p.relative_to(tmp)) for p in tmp.rglob("*.log")],
+                [str(journal.relative_to(tmp))],
+            )
+
+    def test_a_live_sqlite_study_is_backed_by_a_database_under_the_user_data_dir(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False, storage="sqlite")
+            study = single_study(model)
+            database = model.full_path / "optuna-BTC.sqlite"
+            self.assertTrue(database.is_file())
+            self.assertEqual(database.parent, model.full_path)
+            self.assertEqual(
+                [str(path.relative_to(tmp)) for path in tmp.rglob("*.sqlite")],
+                [str(database.relative_to(tmp))],
+            )
+            self.assertEqual(list(tmp.rglob("*.log")), [])
+            # A fresh storage over the same file sees the study, which is what makes the
+            # sqlite backend persistent where the in-memory one is not.
+            reopened = optuna.load_study(
+                study_name=study.study_name, storage=model.optuna_create_storage(PAIR)
+            )
+            self.assertEqual(reopened.user_attrs, {"objective_identity": HP_IDENTITY})
+
+    def test_a_non_live_study_is_backed_by_memory_and_leaves_no_file_behind(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=False)
+            before = tree(tmp)
+            study = single_study(model)
+            self.assertIsInstance(study._storage, optuna.storages.InMemoryStorage)
+            self.assertEqual(study.user_attrs, {"objective_identity": HP_IDENTITY})
+            self.assertEqual(tree(tmp), before)
+            self.assertEqual(list(tmp.rglob("*.log")), [])
+            self.assertEqual(list(tmp.rglob("*.sqlite")), [])
+
+    def test_an_unknown_storage_backend_yields_no_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False, storage="carrier-pigeon")
+            with self.assertLogs(LOGGER, "ERROR"):
+                self.assertIsNone(single_study(model))
+
+    def test_an_unknown_sampler_is_refused_before_the_study_is_built(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, sampler="simulated-annealing")
+            with self.assertRaises(ValueError) as raised:
+                single_study(model)
+            self.assertEqual(
+                str(raised.exception),
+                enum_error_message(
+                    "optuna hp sampler",
+                    "simulated-annealing",
+                    tuple(REGRESSOR._OPTUNA_HPO_SAMPLERS_SET),
+                ),
+            )
+
+    def test_a_label_sampler_outside_the_label_set_is_refused(self):
+        # The two namespaces admit different samplers: nsgaii is valid for label only,
+        # so the refusal has to be proven with a value outside both sets.
+        with temporary_directory() as tmp:
+            model = regressor(tmp, label_sampler="simulated-annealing")
+            with self.assertRaises(ValueError) as raised:
+                multi_study(model)
+            self.assertEqual(
+                str(raised.exception),
+                enum_error_message(
+                    "optuna label sampler",
+                    "simulated-annealing",
+                    tuple(REGRESSOR._OPTUNA_LABEL_SAMPLERS_SET),
+                ),
+            )
+
+    def test_the_two_namespaces_admit_different_sampler_sets(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            self.assertIn("nsgaii", REGRESSOR._OPTUNA_LABEL_SAMPLERS_SET)
+            self.assertNotIn("nsgaii", REGRESSOR._OPTUNA_HPO_SAMPLERS_SET)
+            self.assertEqual(
+                model.optuna_samplers_by_namespace("label"),
+                (REGRESSOR._OPTUNA_LABEL_SAMPLERS_SET, "tpe"),
+            )
+            with self.assertRaises(ValueError):
+                model.optuna_samplers_by_namespace("candle")
+
+    # --------------------------------------------------------- creation failure modes
+
+    def test_a_storage_failure_yields_no_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.optuna_create_storage = mock.Mock(side_effect=OSError("read-only mount"))
+            with self.assertLogs(LOGGER, "ERROR"):
+                self.assertIsNone(single_study(model))
+
+    def test_an_uninspectable_study_yields_no_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            single_study(model)
+            with (
+                mock.patch.object(
+                    REGRESSOR, "optuna_load_study", side_effect=OSError("truncated journal")
+                ),
+                self.assertLogs(LOGGER, "ERROR"),
+            ):
+                self.assertIsNone(single_study(model))
+
+    def test_a_failed_reset_aborts_study_creation_rather_than_loading_a_stale_study(self):
+        # optuna_delete_study returning False is the only signal the reset failed, and
+        # continuing would load the very trials the identity check just rejected.
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            single_study(model)
+            legacy = seed_trial(model, single_study(model), legacy_identity=True)
+
+            with mock.patch.object(REGRESSOR, "optuna_delete_study", return_value=False):
+                self.assertIsNone(single_study(model))
+
+            # The rejected study is still on disk, untouched by the aborted creation.
+            survivor = optuna.load_study(
+                study_name=legacy.study_name, storage=model.optuna_create_storage(PAIR)
+            )
+            self.assertEqual(survivor.user_attrs["objective_identity"], "legacy-identity-v0")
+            self.assertEqual(trial_numbers(survivor), [0])
+
+    def test_a_sampler_failure_yields_no_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            model.optuna_create_sampler = mock.Mock(side_effect=RuntimeError("sampler exploded"))
+            with self.assertLogs(LOGGER, "ERROR"):
+                self.assertIsNone(single_study(model))
+
+    # ---------------------------------------------------------------- validate params
+
+    def test_validation_needs_a_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            self.assertFalse(model.optuna_validate_params(PAIR, "hp", None))
+
+    def test_a_single_objective_study_accepts_exactly_one_finite_stored_value(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = single_study(model)
+            model.set_optuna_value(PAIR, "hp", 0.75)
+            self.assertTrue(model.optuna_validate_params(PAIR, "hp", study))
+
+    def test_a_single_objective_study_rejects_a_non_finite_or_non_numeric_stored_value(self):
+        for value in (np.nan, np.inf, -np.inf, "0.75", None):
+            with self.subTest(value=repr(value)), temporary_directory() as tmp:
+                model = regressor(tmp)
+                study = single_study(model)
+                model.set_optuna_value(PAIR, "hp", value)
+                self.assertFalse(model.optuna_validate_params(PAIR, "hp", study))
+
+    def test_a_multi_objective_study_needs_one_finite_value_per_objective(self):
+        arity = REGRESSOR._OPTUNA_LABEL_N_OBJECTIVES
+        for values, expected in (
+            ([0.5] * arity, True),
+            ([0.5] * (arity - 1), False),
+            ([0.5] * (arity + 1), False),
+            ([np.nan, *([0.5] * (arity - 1))], False),
+            ([np.inf, *([0.5] * (arity - 1))], False),
+            ("not-a-list", False),
+        ):
+            with self.subTest(values=values, expected=expected), temporary_directory() as tmp:
+                model = regressor(tmp)
+                study = multi_study(model)
+                model.set_optuna_values(PAIR, "label", values)
+                self.assertEqual(model.optuna_validate_params(PAIR, "label", study), expected)
+
+    # ----------------------------------------------------------------- enqueue warm start
+
+    def test_the_previous_best_params_are_pinned_into_the_next_trial(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = single_study(model)
+            model.set_optuna_value(PAIR, "hp", 0.5)
+            model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+
+            model.optuna_enqueue_previous_best_params(PAIR, "hp", study)
+            self.assertEqual(waiting_params(study), [{"alpha": 0.25}])
+
+            study.optimize(lambda trial: trial.suggest_float("alpha", 0.0, 1.0), n_trials=1)
+            self.assertEqual(study.trials[-1].params, {"alpha": 0.25})
+            self.assertEqual(study.trials[-1].value, 0.25)
+
+    def test_label_params_are_enqueued_in_the_label_shape(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = multi_study(model)
+            model.set_optuna_values(
+                PAIR, "label", [float(i) for i in range(REGRESSOR._OPTUNA_LABEL_N_OBJECTIVES)]
+            )
+            model.set_optuna_params(PAIR, "label", dict(LABEL_PARAMS))
+
+            model.optuna_enqueue_previous_best_params(PAIR, "label", study)
+            self.assertEqual(waiting_params(study), [dict(LABEL_PARAMS)])
+
+    def test_nothing_is_enqueued_without_a_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            model.set_optuna_value(PAIR, "hp", 0.5)
+            model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+            self.assertIsNone(model.optuna_enqueue_previous_best_params(PAIR, "hp", None))
+
+    def test_nothing_is_enqueued_when_no_previous_params_are_stored(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = single_study(model)
+            model.set_optuna_value(PAIR, "hp", 0.5)
+            model.set_optuna_params(PAIR, "hp", {})
+            model.optuna_enqueue_previous_best_params(PAIR, "hp", study)
+            self.assertEqual(waiting_params(study), [])
+
+    def test_nothing_is_enqueued_while_the_stored_best_value_is_invalid(self):
+        for value in (np.nan, np.inf, None):
+            with self.subTest(value=repr(value)), temporary_directory() as tmp:
+                model = regressor(tmp)
+                study = single_study(model)
+                model.set_optuna_value(PAIR, "hp", value)
+                model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+                model.optuna_enqueue_previous_best_params(PAIR, "hp", study)
+                self.assertEqual(waiting_params(study), [])
+
+    def test_an_unusable_param_payload_is_dropped_with_a_warning(self):
+        # The guard is `if not best_params`, so a truthy non-mapping reaches
+        # enqueue_trial, which refuses it; the warm start must not abort the run for it.
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = single_study(model)
+            model.set_optuna_value(PAIR, "hp", 0.5)
+            model.set_optuna_params(PAIR, "hp", "alpha=0.25")
+
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                model.optuna_enqueue_previous_best_params(PAIR, "hp", study)
+            assert_logged(self, captured.output, "failed to enqueue previous best params")
+            self.assertEqual(waiting_params(study), [])
+
+    # --------------------------------------------------------------------- has best trial
+
+    def test_no_study_has_no_best_trial(self):
+        self.assertFalse(REGRESSOR.optuna_study_has_best_trial(None))
+        self.assertFalse(REGRESSOR.optuna_study_has_best_trials(None))
+
+    def test_an_empty_study_has_no_best_trial(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            self.assertFalse(REGRESSOR.optuna_study_has_best_trial(single_study(model)))
+
+    def test_a_study_whose_trials_never_completed_has_no_best_trial(self):
+        for state in (TrialState.FAIL, TrialState.PRUNED, TrialState.WAITING, TrialState.RUNNING):
+            with self.subTest(state=state.name), temporary_directory() as tmp:
+                model = regressor(tmp)
+                study = single_study(model)
+                study.add_trial(frozen(0, state=state))
+                self.assertEqual(len(study.trials), 1)
+                self.assertFalse(REGRESSOR.optuna_study_has_best_trial(study))
+
+    def test_a_completed_trial_makes_a_best_trial_available(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = single_study(model)
+            study.add_trial(frozen(0, state=TrialState.FAIL))
+            self.assertFalse(REGRESSOR.optuna_study_has_best_trial(study))
+
+            study.add_trial(
+                frozen(1, value=0.75, params={"alpha": 0.5}, distributions={"alpha": ALPHA})
+            )
+            self.assertTrue(REGRESSOR.optuna_study_has_best_trial(study))
+            self.assertEqual(study.best_value, 0.75)
+
+    def test_best_trials_is_the_multi_objective_gate_and_never_filters_on_state(self):
+        # optuna's best_trials returns an empty list rather than raising, so this guard
+        # passes even for an empty study; the real emptiness filter is the best_trials
+        # comprehension in _get_multi_objective_study_best_trial.
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            study = multi_study(model)
+            self.assertEqual(len(study.best_trials), 0)
+            self.assertTrue(REGRESSOR.optuna_study_has_best_trials(study))
+
+            study.add_trial(frozen(0, state=TrialState.FAIL))
+            self.assertTrue(REGRESSOR.optuna_study_has_best_trials(study))
+
+            study.add_trial(
+                frozen(
+                    1,
+                    values=[1.0] * REGRESSOR._OPTUNA_LABEL_N_OBJECTIVES,
+                    params={"label_period_candles": 3},
+                    distributions={"label_period_candles": LABEL_CANDLES},
+                )
+            )
+            self.assertEqual(len(study.best_trials), 1)
+            self.assertTrue(REGRESSOR.optuna_study_has_best_trials(study))
+
+    def test_has_best_trial_refuses_a_multi_objective_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            with self.assertRaisesRegex(RuntimeError, "multi-objective"):
+                REGRESSOR.optuna_study_has_best_trial(multi_study(model))
+
+    # -------------------------------------------------------------- save / load best params
+
+    def test_hp_best_params_round_trip_under_the_objective_identity(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+            model.optuna_save_best_params(PAIR, "hp")
+
+            path = best_params_path(model, "hp")
+            self.assertEqual(path.parent, model.full_path)
+            self.assertEqual(
+                json.loads(path.read_text(encoding="utf-8")),
+                {"objective_identity": HP_IDENTITY, "params": {"alpha": 0.25}},
+            )
+            self.assertEqual(model.optuna_load_best_params(PAIR, "hp"), {"alpha": 0.25})
+
+    def test_hp_best_params_are_ignored_when_the_objective_identity_moves(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+            model.optuna_save_best_params(PAIR, "hp")
+            path = best_params_path(model, "hp")
+
+            rewrite_payload(path, lambda payload: payload.update(objective_identity="legacy-v0"))
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                self.assertIsNone(model.optuna_load_best_params(PAIR, "hp"))
+            assert_logged(self, captured.output, "objective identity does not match")
+
+            rewrite_payload(path, lambda payload: payload.update(objective_identity=HP_IDENTITY))
+            self.assertEqual(model.optuna_load_best_params(PAIR, "hp"), {"alpha": 0.25})
+
+    def test_hp_best_params_are_ignored_when_the_params_payload_is_not_a_mapping(self):
+        for payload in (0.25, "alpha", [0.25], None):
+            with self.subTest(params=payload), temporary_directory() as tmp:
+                model = regressor(tmp, live=True, continuous=False)
+                model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+                model.optuna_save_best_params(PAIR, "hp")
+                path = best_params_path(model, "hp")
+                rewrite_payload(path, lambda saved, value=payload: saved.update(params=value))
+                self.assertIsNone(model.optuna_load_best_params(PAIR, "hp"))
+
+    def test_hp_best_params_are_ignored_when_the_payload_is_not_a_mapping(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+            model.optuna_save_best_params(PAIR, "hp")
+            path = best_params_path(model, "hp")
+            path.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
+            self.assertIsNone(model.optuna_load_best_params(PAIR, "hp"))
+
+    def test_label_best_params_round_trip_under_the_selection_schema(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "label", dict(LABEL_PARAMS))
+            model.optuna_save_best_params(PAIR, "label")
+
+            path = best_params_path(model, "label")
+            payload = json.loads(path.read_text(encoding="utf-8"))
+            self.assertEqual(sorted(payload), ["params", "schema_version", "selection_metadata"])
+            self.assertEqual(payload["params"], dict(LABEL_PARAMS))
+            self.assertEqual(
+                payload["selection_metadata"], model._optuna_label_selection_metadata()
+            )
+            self.assertEqual(model.optuna_load_best_params(PAIR, "label"), dict(LABEL_PARAMS))
+
+    def test_label_best_params_are_ignored_when_the_wire_schema_version_moves(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "label", dict(LABEL_PARAMS))
+            model.optuna_save_best_params(PAIR, "label")
+            path = best_params_path(model, "label")
+
+            def bump(saved: dict) -> None:
+                saved["schema_version"] += 1
+
+            rewrite_payload(path, bump)
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                self.assertIsNone(model.optuna_load_best_params(PAIR, "label"))
+            assert_logged(self, captured.output, "incompatible schema_version=3")
+
+    def test_label_best_params_are_ignored_when_the_selection_schema_version_moves(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "label", dict(LABEL_PARAMS))
+            model.optuna_save_best_params(PAIR, "label")
+            path = best_params_path(model, "label")
+
+            def roll_back(saved: dict) -> None:
+                saved["selection_metadata"]["schema_version"] -= 1
+
+            rewrite_payload(path, roll_back)
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                self.assertIsNone(model.optuna_load_best_params(PAIR, "label"))
+            assert_logged(self, captured.output, "incompatible selection_metadata.schema_version=2")
+
+    def test_label_best_params_are_ignored_when_the_selection_metadata_drifts(self):
+        # The wire schema and the selection schema version both still match; only the
+        # whole-metadata comparison catches this, which is what the regressor wrapper adds
+        # over the strategy-side load that passes no expected metadata.
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            model.set_optuna_params(PAIR, "label", dict(LABEL_PARAMS))
+            model.optuna_save_best_params(PAIR, "label")
+            path = best_params_path(model, "label")
+
+            def swap_metric(saved: dict) -> None:
+                saved["selection_metadata"]["method_config"]["distance_metric"] = "manhattan"
+
+            rewrite_payload(path, swap_metric)
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                self.assertIsNone(model.optuna_load_best_params(PAIR, "label"))
+            assert_logged(self, captured.output, "selection_metadata drift")
+
+    def test_label_best_params_are_ignored_when_a_required_param_is_out_of_range(self):
+        for params in (
+            {"label_period_candles": 0, "label_natr_multiplier": 1.0},
+            {"label_period_candles": 2, "label_natr_multiplier": 0.0},
+            {"label_period_candles": 2},
+            {"label_period_candles": 2, "label_natr_multiplier": 1.0, "label_horizon_candles": 0},
+        ):
+            with self.subTest(params=params), temporary_directory() as tmp:
+                model = regressor(tmp, live=True, continuous=False)
+                model.set_optuna_params(PAIR, "label", dict(LABEL_PARAMS))
+                model.optuna_save_best_params(PAIR, "label")
+                path = best_params_path(model, "label")
+                rewrite_payload(path, lambda saved, value=params: saved.update(params=value))
+                self.assertIsNone(model.optuna_load_best_params(PAIR, "label"))
+
+    def test_absent_best_params_load_as_none_for_both_namespaces(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            self.assertIsNone(model.optuna_load_best_params(PAIR, "hp"))
+            self.assertIsNone(model.optuna_load_best_params(PAIR, "label"))
+
+    # ------------------------------------------------------------------- delete and load
+
+    def test_loading_a_study_that_was_never_created_yields_none(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            storage = model.optuna_create_storage(PAIR)
+            self.assertIsNone(REGRESSOR.optuna_load_study(study_name(model, PAIR, "hp"), storage))
+
+    def test_a_present_study_can_be_loaded_deleted_and_gone(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            name = study_name(model, PAIR, "hp")
+            storage = model.optuna_create_storage(PAIR)
+            single_study(model)
+
+            loaded = REGRESSOR.optuna_load_study(name, model.optuna_create_storage(PAIR))
+            self.assertIsNotNone(loaded)
+            self.assertEqual(loaded.user_attrs, {"objective_identity": HP_IDENTITY})
+
+            self.assertTrue(REGRESSOR.optuna_delete_study(PAIR, "hp", name, storage))
+            self.assertIsNone(REGRESSOR.optuna_load_study(name, model.optuna_create_storage(PAIR)))
+
+    def test_deleting_an_absent_study_is_a_benign_no_op(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            self.assertTrue(
+                REGRESSOR.optuna_delete_study(
+                    PAIR, "hp", study_name(model, PAIR, "hp"), model.optuna_create_storage(PAIR)
+                )
+            )
+
+    def test_a_failing_deletion_is_reported_and_not_swallowed(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False)
+            storage = mock.Mock()
+            storage.delete_study.side_effect = OSError("read-only mount")
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                self.assertFalse(
+                    REGRESSOR.optuna_delete_study(
+                        PAIR, "hp", study_name(model, PAIR, "hp"), storage
+                    )
+                )
+            assert_logged(self, captured.output, "deletion failed")
+
+    # ------------------------------------------------------------------------ optimize
+
+    def test_optimize_records_the_best_value_and_params_of_a_single_objective_study(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False, n_trials=3, n_jobs=1, timeout=60)
+            study = model.optuna_optimize(
+                PAIR,
+                "hp",
+                lambda trial: trial.suggest_float("alpha", 0.0, 1.0),
+                direction=optuna.study.StudyDirection.MAXIMIZE,
+            )
+            self.assertEqual(len(study.trials), 3)
+            self.assertEqual(model.get_optuna_params(PAIR, "hp"), study.best_params)
+            self.assertAlmostEqual(model.get_optuna_value(PAIR, "hp"), study.best_value)
+
+    def test_optimize_seeds_the_study_only_when_warm_start_is_configured(self):
+        # Warm start replays the previous cutoff's best params as the next trial. Both
+        # branches share one study, so the enqueue is visible as a trial whose params are
+        # exactly the stored best and not a fresh sampler draw.
+        for warm_start in (True, False):
+            with self.subTest(warm_start=warm_start), temporary_directory() as tmp:
+                model = regressor(
+                    tmp,
+                    live=True,
+                    continuous=False,
+                    warm_start=warm_start,
+                    n_trials=1,
+                    n_jobs=1,
+                    timeout=60,
+                )
+                model.set_optuna_value(PAIR, "hp", 0.5)
+                model.set_optuna_params(PAIR, "hp", {"alpha": 0.25})
+                study = model.optuna_optimize(
+                    PAIR,
+                    "hp",
+                    lambda trial: trial.suggest_float("alpha", 0.0, 1.0),
+                    direction=optuna.study.StudyDirection.MAXIMIZE,
+                )
+                self.assertEqual(len(study.trials), 1)
+                trial = study.trials[0]
+                # optuna records an enqueued trial's pinned params under fixed_params and
+                # omits them for a sampler-drawn one, so the attribute discriminates the
+                # two branches without depending on what the sampler happened to draw.
+                self.assertEqual("fixed_params" in trial.system_attrs, warm_start)
+                if warm_start:
+                    self.assertEqual(trial.params, {"alpha": 0.25})
+                    self.assertEqual(trial.value, 0.25)
+
+    def test_optimize_persists_best_params_only_while_live(self):
+        for live in (True, False):
+            with self.subTest(live=live), temporary_directory() as tmp:
+                model = regressor(
+                    tmp, live=live, continuous=False, n_trials=1, n_jobs=1, timeout=60
+                )
+                model.optuna_optimize(
+                    PAIR,
+                    "hp",
+                    lambda trial: trial.suggest_float("alpha", 0.0, 1.0),
+                    direction=optuna.study.StudyDirection.MAXIMIZE,
+                )
+                path = model.full_path / "optuna-hp-best-params-BTC_USDT.json"
+                self.assertEqual(path.is_file(), live)
+
+    def test_optimize_yields_no_study_when_no_trial_completed(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp, live=True, continuous=False, n_trials=1, n_jobs=1, timeout=60)
+
+            def exploding(trial: object) -> float:
+                raise RuntimeError("objective exploded")
+
+            with self.assertLogs(LOGGER, "ERROR"):
+                self.assertIsNone(
+                    model.optuna_optimize(
+                        PAIR,
+                        "hp",
+                        exploding,
+                        direction=optuna.study.StudyDirection.MAXIMIZE,
+                    )
+                )
+
+    def test_optimize_refuses_to_persist_a_preserved_incompatible_label_study(self):
+        # A preserved study carries the old selection schema, so the trials it now holds
+        # were selected by that schema; persisting them would let the next start load them.
+        with temporary_directory() as tmp:
+            model = regressor(
+                tmp,
+                live=True,
+                continuous=False,
+                reset_label_study_on_schema_mismatch=False,
+                n_trials=1,
+                n_jobs=1,
+                timeout=60,
+            )
+            first = multi_study(model)
+            first.set_user_attr("selection_metadata", {"schema_version": 2})
+
+            with self.assertLogs(LOGGER, "WARNING") as captured:
+                model.optuna_optimize(
+                    PAIR,
+                    "label",
+                    lambda trial: [
+                        float(index) for index in range(REGRESSOR._OPTUNA_LABEL_N_OBJECTIVES)
+                    ],
+                    directions=LABEL_DIRECTIONS,
+                )
+            assert_logged(self, captured.output, "best params not persisted")
+            self.assertFalse(best_params_path(model, "label").exists())
+
+    # -------------------------------------------------- selection metadata compatibility
+
+    def test_the_current_selection_schema_is_compatible(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            self.assertTrue(
+                REGRESSOR._optuna_label_selection_metadata_compatible(
+                    model._optuna_label_selection_metadata()
+                )
+            )
+
+    def test_a_numpy_integer_selection_schema_version_is_compatible(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            metadata = dict(model._optuna_label_selection_metadata())
+            metadata["schema_version"] = np.int64(metadata["schema_version"])
+            self.assertTrue(REGRESSOR._optuna_label_selection_metadata_compatible(metadata))
+
+    def test_a_moved_selection_schema_version_is_not_compatible(self):
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            metadata = dict(model._optuna_label_selection_metadata())
+            metadata["schema_version"] = metadata["schema_version"] - 1
+            self.assertFalse(REGRESSOR._optuna_label_selection_metadata_compatible(metadata))
+
+    def test_metadata_drift_below_the_schema_version_is_still_compatible(self):
+        # Only schema_version decides compatibility; the stored payload is left to the
+        # best-params loader, which compares the whole metadata dict.
+        with temporary_directory() as tmp:
+            model = regressor(tmp)
+            metadata = dict(model._optuna_label_selection_metadata())
+            metadata["method_config"] = {"category": "distance", "method": "topsis"}
+            metadata["label_weights"] = [0.5, 0.5]
+            self.assertTrue(REGRESSOR._optuna_label_selection_metadata_compatible(metadata))
+
+    def test_a_missing_or_non_dict_marker_is_not_compatible(self):
+        for marker in ({}, {"schema_version": None}, None, "compromise_programming", 3, []):
+            with self.subTest(marker=marker):
+                self.assertFalse(REGRESSOR._optuna_label_selection_metadata_compatible(marker))
+
+    def test_a_boolean_is_not_an_integer_selection_schema_version(self):
+        for version in (True, False):
+            with self.subTest(schema_version=version):
+                self.assertFalse(
+                    REGRESSOR._optuna_label_selection_metadata_compatible(
+                        {"schema_version": version}
+                    )
+                )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_regressor_prediction_dedup.py b/quickadapter/tests/test_regressor_prediction_dedup.py
new file mode 100644 (file)
index 0000000..043c5f2
--- /dev/null
@@ -0,0 +1,329 @@
+"""Provability ranking and per-candle deduplication of historic FreqAI predictions."""
+
+import unittest
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+import pandas as pd
+from freqtrade.freqai.data_drawer import FreqaiDataDrawer
+from qa_support import PAIR, QaTestCase
+
+import quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 as regressor
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import (
+    _align_historic_predictions,
+    _dedupe_historic_predictions_on_date_pred,
+    _ensure_produced_prediction_column,
+    _legacy_produced_prediction_mask,
+    _produced_prediction_mask,
+    _recorded_prediction_rank,
+)
+
+MARKER = regressor._PRODUCED_PREDICTION_COLUMN
+DATES = pd.date_range("2026-01-01", periods=4, freq="5min", tz="UTC")
+
+# One expired (`do_predict == 2`) row per marker form. `np.nan` in the marker column is the
+# "recorded but unlabelled" form; omitting the key drops the column entirely. The
+# close-bearing row is the legacy shape, which carries a positive price instead of a marker.
+_EXPIRED_FORMS: dict[str, dict[str, Any]] = {
+    "explicit marker": {"close_price": [np.nan], MARKER: [True]},
+    "null marker": {"close_price": [np.nan], MARKER: [np.nan]},
+    "absent marker column": {"close_price": [np.nan]},
+    "close-bearing legacy row": {"close_price": [100.0]},
+}
+
+
+def drawer_stub(history: pd.DataFrame) -> SimpleNamespace:
+    """A drawer exposing exactly what the patched attach method reads and writes."""
+    stub = SimpleNamespace(historic_predictions={PAIR: history}, model_return_values={})
+    stub._ensure_date_pred_dtype = FreqaiDataDrawer._ensure_date_pred_dtype
+    return stub
+
+
+class RegressorPredictionDedupTest(QaTestCase):
+    def test_rank_orders_proven_then_close_then_placeholder(self):
+        frame = pd.DataFrame(
+            {
+                "date_pred": [DATES[0]] * 10,
+                "do_predict": [1, 3, 0, 0, 2, 0, 0, 0, 0, 1],
+                "close_price": [
+                    np.nan,
+                    np.nan,
+                    100.0,
+                    np.nan,
+                    100.0,
+                    np.nan,
+                    0.0,
+                    -100.0,
+                    np.inf,
+                    np.nan,
+                ],
+                MARKER: [
+                    True,
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                    False,
+                    np.nan,
+                    np.nan,
+                    np.nan,
+                    False,
+                ],
+                "value": [float(i) for i in range(1, 11)],
+            }
+        )
+        # The last row carries a nonzero status yet an explicit False marker: the recorded
+        # status must not resurrect it, and the close bound must exclude zero, negatives and inf.
+        self.assertEqual(_recorded_prediction_rank(frame).tolist(), [2, 2, 1, 0, 0, 0, 0, 0, 0, 0])
+        self.assertEqual(
+            _legacy_produced_prediction_mask(frame).tolist(),
+            [True, True, False, False, False, False, False, False, False, True],
+        )
+        self.assertEqual(
+            _produced_prediction_mask(frame).tolist(),
+            [True, True, False, False, False, False, False, False, False, False],
+        )
+
+    def test_expired_status_is_never_produced_on_any_marker_form(self):
+        for form, columns in _EXPIRED_FORMS.items():
+            for status, produced in ((2, False), (1, True)):
+                with self.subTest(form=form, do_predict=status):
+                    frame = pd.DataFrame(
+                        {"date_pred": DATES[:1], "do_predict": [status], **columns}
+                    )
+                    self.assertEqual(_produced_prediction_mask(frame).tolist(), [produced])
+                    self.assertEqual(
+                        _recorded_prediction_rank(frame).tolist(), [2 if produced else 0]
+                    )
+                    labelled = _ensure_produced_prediction_column(frame)
+                    self.assertEqual(_produced_prediction_mask(labelled).tolist(), [produced])
+
+    def test_ensure_column_keeps_an_explicit_marker_on_an_expired_row(self):
+        frame = pd.DataFrame(
+            {"date_pred": DATES[:1], "do_predict": [2], "close_price": [np.nan], MARKER: [True]}
+        )
+        self.assertEqual(_ensure_produced_prediction_column(frame)[MARKER].tolist(), [True])
+        self.assertEqual(_produced_prediction_mask(frame).tolist(), [False])
+
+    def test_dedup_applies_the_full_tier_order_to_every_candle(self):
+        # Four candles, four distinct tier pairs, deliberately out of date order so the sort is
+        # load-bearing. Three candles put the proven or close-bearing row before its rival, so the
+        # rank must outrank the original position: a proven row beats downtime (D0), a close beats
+        # downtime (D1), an explicit marker beats a close (D2), and a close beats an expired
+        # model (D3). Each candle has one unambiguous winner.
+        frame = pd.DataFrame(
+            {
+                "date_pred": [
+                    DATES[2],
+                    DATES[0],
+                    DATES[3],
+                    DATES[1],
+                    DATES[1],
+                    DATES[2],
+                    DATES[0],
+                    DATES[3],
+                ],
+                "do_predict": [1, 0, 0, 0, 0, 0, 1, 2],
+                "close_price": [np.nan, np.nan, 100.0, 100.0, np.nan, 100.0, np.nan, np.nan],
+                MARKER: [True, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan],
+                "value": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
+            }
+        )
+        self.assertEqual(_recorded_prediction_rank(frame).tolist(), [2, 0, 1, 1, 0, 1, 2, 0])
+        result = _dedupe_historic_predictions_on_date_pred(frame, PAIR)
+        self.assertEqual(result["date_pred"].tolist(), list(DATES))
+        self.assertEqual(result["value"].tolist(), [7.0, 4.0, 1.0, 3.0])
+
+    def test_dedup_keeps_one_row_per_date_in_ascending_order_with_last_write_wins(self):
+        frame = pd.DataFrame(
+            {
+                "date_pred": [
+                    DATES[2],
+                    DATES[0],
+                    DATES[2],
+                    DATES[0],
+                    DATES[1],
+                    DATES[3],
+                    DATES[3],
+                ],
+                "do_predict": [0] * 7,
+                "close_price": [np.nan] * 7,
+                "value": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
+            }
+        )
+        result = _dedupe_historic_predictions_on_date_pred(frame, PAIR)
+        self.assertEqual(result["date_pred"].tolist(), list(DATES))
+        self.assertTrue(result["date_pred"].is_unique)
+        self.assertEqual(result["value"].tolist(), [4.0, 5.0, 3.0, 7.0])
+        self.assertEqual(result.index.tolist(), [0, 1, 2, 3])
+
+    def test_dedup_drops_unparseable_date_pred_with_a_warning(self):
+        frame = pd.DataFrame(
+            {
+                "date_pred": [*DATES[:3], "not-a-date", None],
+                "do_predict": [0] * 5,
+                "value": [1.0, 2.0, 3.0, 4.0, 5.0],
+            }
+        )
+        with self.assertLogs(regressor.logger, level="WARNING") as captured:
+            result = _dedupe_historic_predictions_on_date_pred(frame, PAIR)
+        self.assertEqual(len(captured.records), 1)
+        self.assertEqual(
+            captured.records[0].getMessage(),
+            f"FreqAI prediction history [{PAIR}]: discarded invalid date_pred entries (count=2)",
+        )
+        self.assertEqual(result["date_pred"].tolist(), list(DATES[:3]))
+        self.assertEqual(result["value"].tolist(), [1.0, 2.0, 3.0])
+
+    def test_fast_path_returns_the_same_object_when_already_normalized(self):
+        frame = pd.DataFrame(
+            {
+                "date_pred": list(DATES),
+                "do_predict": [0, 1, 2, 0],
+                "close_price": [np.nan] * 4,
+            }
+        )
+        with self.assertNoLogs(regressor.logger, level="WARNING"):
+            result = _dedupe_historic_predictions_on_date_pred(frame, PAIR)
+        self.assertIs(result, frame)
+        self.assertEqual(result["date_pred"].dtype, frame["date_pred"].dtype)
+
+    def test_fast_path_corrects_a_timezone_naive_date_pred_column(self):
+        naive = pd.DataFrame(
+            {
+                "date_pred": pd.DatetimeIndex(DATES).tz_localize(None),
+                "do_predict": [0, 1, 2, 0],
+                "value": [1.0, 2.0, 3.0, 4.0],
+            }
+        )
+        result = _dedupe_historic_predictions_on_date_pred(naive, PAIR)
+        self.assertIsNot(result, naive)
+        self.assertEqual(result["date_pred"].dtype, DATES.dtype)
+        self.assertEqual(result["date_pred"].tolist(), list(DATES))
+        self.assertEqual(naive["date_pred"].dtype, pd.DatetimeIndex(DATES).tz_localize(None).dtype)
+
+    def test_slow_path_normalizes_surviving_dates_after_ranking(self):
+        naive_dates = pd.DatetimeIndex(DATES).tz_localize(None)
+        frame = pd.DataFrame(
+            {
+                # A duplicate forces the ranking slow path, where the survivors are rewritten
+                # from the parsed UTC series rather than from the raw column.
+                "date_pred": [naive_dates[0], naive_dates[0], naive_dates[1]],
+                "do_predict": [0, 1, 0],
+                "close_price": [np.nan] * 3,
+                "value": [1.0, 2.0, 3.0],
+            }
+        )
+        result = _dedupe_historic_predictions_on_date_pred(frame, PAIR)
+        self.assertEqual(result["value"].tolist(), [2.0, 3.0])
+        self.assertEqual(result["date_pred"].dtype, DATES.dtype)
+        self.assertEqual(result["date_pred"].tolist(), [DATES[0], DATES[1]])
+
+    def test_ensure_produced_prediction_column_copies_instead_of_mutating(self):
+        without = pd.DataFrame({"date_pred": list(DATES), "do_predict": [1, 0, 2, 0]})
+        result = _ensure_produced_prediction_column(without)
+        self.assertIsNot(result, without)
+        self.assertNotIn(MARKER, without.columns)
+        self.assertEqual(result[MARKER].tolist(), [True, False, False, False])
+
+        partial = pd.DataFrame(
+            {
+                "date_pred": list(DATES),
+                "do_predict": [1, 0, 2, 0],
+                MARKER: [True, np.nan, True, np.nan],
+            }
+        )
+        filled = _ensure_produced_prediction_column(partial)
+        self.assertIsNot(filled, partial)
+        self.assertEqual(partial[MARKER].isna().tolist(), [False, True, False, True])
+        self.assertEqual(filled[MARKER].tolist(), [True, False, True, False])
+
+        complete = pd.DataFrame(
+            {
+                "date_pred": list(DATES),
+                "do_predict": [1, 0, 2, 0],
+                MARKER: [True, False, True, False],
+            }
+        )
+        self.assertIs(_ensure_produced_prediction_column(complete), complete)
+
+    def test_align_rebuilds_model_return_values_against_the_requested_candle_dates(self):
+        history = pd.DataFrame(
+            {
+                "date_pred": [DATES[0], DATES[2]],
+                "value": [10.0, 30.0],
+                "do_predict": [1, 1],
+                MARKER: [True, True],
+            }
+        )
+        strategy = pd.DataFrame(
+            {"date": list(DATES), "close": [1.0, 2.0, 3.0, 4.0]}, index=[7, 8, 9, 10]
+        )
+        aligned = _align_historic_predictions(history, strategy)
+        self.assertEqual(aligned.index.tolist(), strategy.index.tolist())
+        self.assertEqual(aligned["date_pred"].tolist(), strategy["date"].tolist())
+        pd.testing.assert_series_equal(
+            aligned["value"],
+            pd.Series([10.0, np.nan, 30.0, np.nan], index=strategy.index, name="value"),
+        )
+        pd.testing.assert_series_equal(
+            aligned["do_predict"],
+            pd.Series([1.0, 0.0, 1.0, 0.0], index=strategy.index, name="do_predict"),
+        )
+
+        drawer = drawer_stub(history)
+        merged = FreqaiDataDrawer.attach_return_values_to_return_dataframe(drawer, PAIR, strategy)
+        pd.testing.assert_frame_equal(drawer.model_return_values[PAIR], aligned)
+        self.assertEqual(merged["date"].tolist(), strategy["date"].tolist())
+        pd.testing.assert_series_equal(
+            merged["value"],
+            pd.Series([10.0, np.nan, 30.0, np.nan], name="value"),
+            check_names=False,
+        )
+
+    def test_align_zeroes_do_predict_when_the_history_carries_no_status_column(self):
+        history = pd.DataFrame({"date_pred": [DATES[1]], "value": [20.0]})
+        strategy = pd.DataFrame({"date": list(DATES)})
+        aligned = _align_historic_predictions(history, strategy)
+        self.assertEqual(aligned["do_predict"].tolist(), [0, 0, 0, 0])
+        self.assertEqual(aligned["value"].isna().tolist(), [True, False, True, True])
+
+    def test_align_rejects_a_duplicate_date_pred(self):
+        duplicated = pd.DataFrame(
+            {"date_pred": [DATES[0], DATES[0]], "value": [1.0, 2.0], "do_predict": [0, 1]}
+        )
+        strategy = pd.DataFrame({"date": list(DATES)})
+        with self.assertRaisesRegex(ValueError, "duplicate labels"):
+            _align_historic_predictions(duplicated, strategy)
+
+    def test_patched_attach_deduplicates_before_it_aligns(self):
+        duplicated = pd.DataFrame(
+            {
+                "date_pred": [DATES[0], DATES[0], DATES[2]],
+                "value": [10.0, 11.0, 30.0],
+                "do_predict": [0, 1, 1],
+            }
+        )
+        strategy = pd.DataFrame({"date": list(DATES)})
+        drawer = drawer_stub(duplicated)
+        FreqaiDataDrawer.attach_return_values_to_return_dataframe(drawer, PAIR, strategy)
+        repaired = drawer.historic_predictions[PAIR]
+        self.assertTrue(repaired["date_pred"].is_unique)
+        self.assertEqual(repaired["value"].tolist(), [11.0, 30.0])
+        self.assertEqual(drawer.model_return_values[PAIR].index.tolist(), strategy.index.tolist())
+
+    def test_patched_attach_raises_key_error_without_a_date_column(self):
+        history = pd.DataFrame(
+            {"date_pred": [DATES[0]], "value": [10.0], "do_predict": [1]},
+        )
+        drawer = drawer_stub(history)
+        strategy = pd.DataFrame({"date": list(DATES)}).drop(columns=["date"])
+        with self.assertRaises(KeyError) as caught:
+            FreqaiDataDrawer.attach_return_values_to_return_dataframe(drawer, PAIR, strategy)
+        self.assertEqual(caught.exception.args[0], "date")
+        self.assertNotIn(PAIR, drawer.model_return_values)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_regressor_prediction_history.py b/quickadapter/tests/test_regressor_prediction_history.py
new file mode 100644 (file)
index 0000000..6c58b4f
--- /dev/null
@@ -0,0 +1,851 @@
+"""Calibration warmup arithmetic, DI cutoff fallbacks and throttling; requires the Freqtrade QA image."""
+
+import random
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+import numpy as np
+import pandas as pd
+import scipy as sp
+from freqtrade.freqai.freqai_interface import IFreqaiModel
+from qa_support import PAIR, QaTestCase
+from Utils import _OPTUNA_NAMESPACES, DEFAULTS_LABEL_PIPELINE
+
+from quickadapter.user_data.freqaimodels import QuickAdapterRegressorV3 as regressor_module
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import (
+    _PRODUCED_PREDICTION_COLUMN,
+    QuickAdapterRegressorV3,
+    _log_known_at_none_once,
+)
+
+LABEL = "&s-extrema"
+CALIBRATION_START_KEY = QuickAdapterRegressorV3._CALIBRATION_START_KEY
+DI_CUTOFF_DEFAULT = QuickAdapterRegressorV3._DI_CUTOFF_DEFAULT
+# The normalized label range the canonical pipeline declares; the cold-start
+# sentinels must sit strictly outside it or they would trigger on every candle.
+LABEL_RANGE = DEFAULTS_LABEL_PIPELINE["minmax_range"]
+TIMEFRAME = "5m"
+CANDLE_FREQ = "5min"
+
+
+def regressor(**attrs: object) -> QuickAdapterRegressorV3:
+    """Build a regressor without running __init__, injecting only what the code under test reads."""
+    model = object.__new__(QuickAdapterRegressorV3)
+    model.live = True
+    model.config = {"timeframe": TIMEFRAME}
+    model.pairs = [PAIR]
+    model._calibration_current_candles = {}
+    model._optuna_label_params = {}
+    model._optuna_hp_params = {}
+    model._holdout_rmse = {}
+    model._session_fitted_pairs = {PAIR}
+    model._optuna_hyperopt = False
+    model.ft_params = {"label_period_candles": 1}
+    model._label_defaults = (1, 5.0)
+    model._optuna_label_candles = {PAIR: 0}
+    model._optuna_label_candle = {PAIR: 0}
+    model._optuna_label_incremented_pairs = []
+    for name, value in attrs.items():
+        setattr(model, name, value)
+    return model
+
+
+def drawer(extras: dict[str, str] | None = None, shared: dict | None = None) -> SimpleNamespace:
+    """A data drawer stub whose pair entries share one extras dict, as Freqtrade's shallow copy does."""
+    empty = {
+        "model_filename": "",
+        "trained_timestamp": 0,
+        "data_path": "",
+        "extras": {} if shared is None else shared,
+    }
+    pair_dict: dict[str, dict] = {
+        PAIR: {**empty, "extras": {} if extras is None else dict(extras)},
+    }
+
+    def get_pair_dict_info(pair: str) -> tuple[str, int]:
+        pair_dict.setdefault(pair, {**empty})
+        return pair_dict[pair]["model_filename"], pair_dict[pair]["trained_timestamp"]
+
+    return SimpleNamespace(
+        pair_dict=pair_dict,
+        get_pair_dict_info=get_pair_dict_info,
+        save_drawer_to_disk=mock.Mock(),
+        historic_predictions={},
+        model_return_values={},
+        empty_pair_dict=empty,
+    )
+
+
+def history(
+    dates: list[str],
+    produced: list[bool],
+    values: list[float] | None = None,
+    di: list[float] | None = None,
+    holdout: list[float] | None = None,
+) -> pd.DataFrame:
+    """Build a persisted prediction history; ``produced`` is the explicit marker column."""
+    index = pd.RangeIndex(len(dates))
+    frame = pd.DataFrame(
+        {
+            "date_pred": pd.to_datetime(pd.Series(dates), utc=True),
+            _PRODUCED_PREDICTION_COLUMN: pd.Series(produced, dtype=object),
+            LABEL: pd.Series([0.0] * len(dates) if values is None else values, dtype="float64"),
+        },
+        index=index,
+    )
+    if di is not None:
+        frame["DI_values"] = pd.Series(di, dtype="float64")
+    if holdout is not None:
+        frame["holdout_rmse"] = pd.Series(holdout, dtype="float64")
+    return frame
+
+
+def kitchen() -> SimpleNamespace:
+    return SimpleNamespace(
+        label_list=[LABEL],
+        unique_class_list=[],
+        data={"extra_returns_per_train": {}},
+        full_df=pd.DataFrame(),
+    )
+
+
+def calibrated(
+    candles: int = 2,
+    di: list[float] | None = None,
+    holdout: list[float] | None = None,
+    budget: int | None = None,
+) -> tuple[QuickAdapterRegressorV3, SimpleNamespace, list[str]]:
+    """A live regressor with `candles` produced observations, ready for fit_live_predictions."""
+    dates = pd.date_range("2026-01-01", periods=candles, freq=CANDLE_FREQ, tz="UTC")
+    frame = history(
+        [d.isoformat() for d in dates],
+        [True] * candles,
+        values=[0.25, -0.25, 0.5, -0.5][:candles],
+        di=di,
+        holdout=holdout,
+    )
+    model = regressor(
+        _fit_live_predictions_candles=candles if budget is None else budget,
+        label_prediction={
+            "default": {
+                "method": "thresholding",
+                "selection_method": "rank_extrema",
+                "threshold_method": "mean",
+                "outlier_quantile": 0.999,
+                "soft_extremum_alpha": 12.0,
+                "keep_fraction": 0.0075,
+            },
+            "columns": {},
+        },
+        min_max_pred=lambda *args, **kwargs: (-0.4, 0.6),
+    )
+    model.dd = drawer()
+    model.dd.historic_predictions[PAIR] = frame
+    return model, kitchen(), [d.isoformat() for d in dates]
+
+
+class RegressorPredictionHistoryTest(QaTestCase):
+    # ------------------------------------------------------------------ calibration warmup
+
+    def test_bootstrap_only_history_restarts_warmup_cold_at_the_decision_time(self):
+        dates = ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"]
+        frame = history(dates, [False, False, False])
+        model = regressor(dd=drawer())
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 00:10", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertTrue(eligible.empty)
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T00:10:00+00:00",
+        )
+
+    def test_boundary_persisted_in_the_future_is_discarded_and_warmup_restarts_cold(self):
+        # A strategy candle that predates every saved prediction: nothing was provably
+        # produced at or before the decision time, so the warmup must restart there
+        # rather than trust a boundary that claims observations from the future.
+        dates = ["2026-01-01 00:30", "2026-01-01 00:35", "2026-01-01 00:40"]
+        frame = history(dates, [True, True, True])
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T01:00:00+00:00"}),
+        )
+        decision = pd.Timestamp("2026-01-01 00:10", tz="UTC")
+        model._calibration_current_candles[PAIR] = decision
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertTrue(eligible.empty)
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T00:10:00+00:00",
+        )
+
+    def test_a_future_boundary_is_rebuilt_from_provable_rows_not_from_the_future(self):
+        dates = ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"]
+        frame = history(dates, [False, True, True])
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T01:00:00+00:00"}),
+        )
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 00:10", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T00:05:00+00:00",
+        )
+        self.assertEqual(
+            eligible["date_pred"].tolist(),
+            [
+                pd.Timestamp("2026-01-01 00:05", tz="UTC"),
+                pd.Timestamp("2026-01-01 00:10", tz="UTC"),
+            ],
+        )
+
+    def test_only_produced_rows_inside_the_persisted_boundary_are_warmup(self):
+        dates = [
+            "2026-01-01 00:00",  # produced, before the persisted start
+            "2026-01-01 00:05",  # produced, exactly on the persisted start
+            "2026-01-01 00:10",  # bootstrap placeholder inside the window
+            "2026-01-01 00:15",  # produced
+            "2026-01-01 00:20",  # produced, at the decision time
+            "2026-01-01 00:25",  # produced, after the decision time
+        ]
+        frame = history(dates, [True, True, False, True, True, True])
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:05:00+00:00"}),
+        )
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 00:20", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 4)
+
+        self.assertEqual(
+            eligible["date_pred"].tolist(),
+            [
+                pd.Timestamp("2026-01-01 00:05", tz="UTC"),
+                pd.Timestamp("2026-01-01 00:15", tz="UTC"),
+                pd.Timestamp("2026-01-01 00:20", tz="UTC"),
+            ],
+        )
+        self.assertEqual(model.dd.save_drawer_to_disk.call_count, 0)
+
+    def test_interior_gap_longer_than_the_candle_budget_moves_the_boundary_to_the_gap_start(self):
+        dates = [
+            "2026-01-01 00:00",
+            "2026-01-01 00:05",
+            "2026-01-01 00:10",
+            "2026-01-01 00:15",
+            "2026-01-01 01:00",
+            "2026-01-01 01:05",
+        ]
+        frame = history(dates, [True] * len(dates))
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:00:00+00:00"}),
+        )
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 01:05", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertEqual(
+            eligible["date_pred"].tolist(),
+            [
+                pd.Timestamp("2026-01-01 01:00", tz="UTC"),
+                pd.Timestamp("2026-01-01 01:05", tz="UTC"),
+            ],
+        )
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T01:00:00+00:00",
+        )
+
+    def test_gap_between_the_boundary_and_the_first_observation_moves_the_boundary_forward(self):
+        # No interior gap at all - the only gap is between the persisted boundary and the
+        # first surviving row, which is the case a diff()-based search cannot see.
+        dates = ["2026-01-01 00:30", "2026-01-01 00:35", "2026-01-01 00:40"]
+        frame = history(dates, [True] * len(dates))
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:00:00+00:00"}),
+        )
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 00:40", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertEqual(
+            eligible["date_pred"].tolist(),
+            [
+                pd.Timestamp("2026-01-01 00:30", tz="UTC"),
+                pd.Timestamp("2026-01-01 00:35", tz="UTC"),
+                pd.Timestamp("2026-01-01 00:40", tz="UTC"),
+            ],
+        )
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T00:30:00+00:00",
+        )
+
+    def test_the_boundary_moves_to_the_last_gap_not_the_first(self):
+        # Two gaps: resuming after the first would leave a second, older-than-budget hole
+        # inside the warmup window, so only the last gap is a valid restart point.
+        dates = [
+            "2026-01-01 00:00",
+            "2026-01-01 00:05",
+            "2026-01-01 01:00",
+            "2026-01-01 01:05",
+            "2026-01-01 02:00",
+            "2026-01-01 02:05",
+        ]
+        frame = history(dates, [True] * len(dates))
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:00:00+00:00"}),
+        )
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 02:05", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertEqual(
+            eligible["date_pred"].tolist(),
+            [
+                pd.Timestamp("2026-01-01 02:00", tz="UTC"),
+                pd.Timestamp("2026-01-01 02:05", tz="UTC"),
+            ],
+        )
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T02:00:00+00:00",
+        )
+
+    def test_observations_older_than_the_candle_budget_start_a_new_warmup(self):
+        dates = ["2026-01-01 00:00", "2026-01-01 00:05"]
+        frame = history(dates, [True, True])
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:00:00+00:00"}),
+        )
+        decision = pd.Timestamp("2026-01-01 02:00", tz="UTC")
+        model._calibration_current_candles[PAIR] = decision
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertTrue(eligible.empty)
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T02:00:00+00:00",
+        )
+
+    def test_gap_exactly_at_the_candle_budget_keeps_the_earlier_boundary(self):
+        dates = ["2026-01-01 00:00", "2026-01-01 00:10", "2026-01-01 00:20"]
+        frame = history(dates, [True] * len(dates))
+        model = regressor(
+            dd=drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:00:00+00:00"}),
+        )
+        model._calibration_current_candles[PAIR] = pd.Timestamp("2026-01-01 00:20", tz="UTC")
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertEqual(len(eligible), 3)
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T00:00:00+00:00",
+        )
+
+    def test_no_decision_time_yields_no_history_and_persists_nothing(self):
+        frame = history(["2026-01-01 00:00"], [True])
+        model = regressor(dd=drawer())
+        model._calibration_current_candles[PAIR] = pd.NaT
+
+        eligible = model._calibration_history(model.dd, PAIR, frame, 2)
+
+        self.assertTrue(eligible.empty)
+        self.assertEqual(model.dd.pair_dict[PAIR]["extras"], {})
+        self.assertEqual(model.dd.save_drawer_to_disk.call_count, 0)
+
+    def test_decision_time_prefers_the_live_candle_then_full_df_then_history(self):
+        frame = history(["2026-01-01 00:00"], [True])
+        model = regressor(dd=drawer())
+        live = model._calibration_current_candles
+
+        live[PAIR] = pd.Timestamp("2026-01-01 00:20", tz="UTC")
+        dk = kitchen()
+        dk.full_df = pd.DataFrame({"date": pd.to_datetime(["2026-01-01 01:00"], utc=True)})
+        self.assertEqual(
+            model._calibration_decision_time(dk, PAIR, frame),
+            pd.Timestamp("2026-01-01 00:20", tz="UTC"),
+        )
+
+        live[PAIR] = pd.NaT
+        self.assertIsNone(model._calibration_decision_time(dk, PAIR, frame))
+
+        live.pop(PAIR)
+        self.assertEqual(
+            model._calibration_decision_time(dk, PAIR, frame),
+            pd.Timestamp("2026-01-01 01:00", tz="UTC"),
+        )
+
+        self.assertEqual(
+            model._calibration_decision_time(kitchen(), PAIR, frame),
+            pd.Timestamp("2026-01-01 00:00", tz="UTC"),
+        )
+        self.assertIsNone(model._calibration_decision_time(kitchen(), PAIR, frame.iloc[:0]))
+
+    def test_persisting_a_boundary_never_mutates_the_drawer_shared_extras(self):
+        # Freqtrade seeds a new pair entry with empty_pair_dict.copy(), a SHALLOW copy, so
+        # every fresh pair shares one extras dict. Persisting in place would hand one
+        # pair's warmup boundary to every other pair on the next restart.
+        shared: dict = {}
+        dd = drawer(shared=shared)
+        model = regressor(dd=dd)
+        fresh = "ETH/USDT"
+        dd.get_pair_dict_info(fresh)
+        self.assertIs(dd.pair_dict[fresh]["extras"], shared)
+
+        model._persist_calibration_start(fresh, pd.Timestamp("2026-01-01 00:05", tz="UTC"))
+
+        self.assertEqual(shared, {})
+        self.assertEqual(
+            dd.pair_dict[fresh]["extras"][CALIBRATION_START_KEY], "2026-01-01T00:05:00+00:00"
+        )
+        self.assertNotIn(CALIBRATION_START_KEY, dd.pair_dict[PAIR]["extras"])
+        dd.save_drawer_to_disk.assert_called_once_with()
+
+    def test_one_pairs_boundary_is_never_visible_to_another_pair(self):
+        shared: dict = {}
+        dd = drawer(shared=shared)
+        model = regressor(dd=dd)
+        other = "ETH/USDT"
+        dd.get_pair_dict_info(other)
+
+        model._persist_calibration_start(PAIR, pd.Timestamp("2026-01-01 00:05", tz="UTC"))
+
+        # The other pair has no boundary of its own, so it migrates from its own
+        # history rather than inheriting BTC's - a shared dict would have leaked it.
+        self.assertNotIn(CALIBRATION_START_KEY, dd.pair_dict[other]["extras"])
+        model._calibration_history(
+            dd,
+            other,
+            history(["2026-01-01 01:00", "2026-01-01 01:05"], [True, True]),
+            2,
+        )
+        self.assertEqual(
+            dd.pair_dict[other]["extras"][CALIBRATION_START_KEY], "2026-01-01T01:00:00+00:00"
+        )
+
+    def test_persisting_an_unchanged_boundary_does_not_rewrite_the_drawer(self):
+        dd = drawer(extras={CALIBRATION_START_KEY: "2026-01-01T00:05:00+00:00"})
+        model = regressor(dd=dd)
+
+        model._persist_calibration_start(PAIR, pd.Timestamp("2026-01-01 00:05", tz="UTC"))
+
+        dd.save_drawer_to_disk.assert_not_called()
+
+    def test_migration_resumes_from_the_earliest_provably_produced_row(self):
+        dates = ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"]
+        frame = history(dates, [False, True, True])
+        model = regressor(dd=drawer())
+        decision = pd.Timestamp("2026-01-01 00:10", tz="UTC")
+
+        start = model._migrate_calibration_start(PAIR, frame, decision)
+
+        self.assertEqual(start, pd.Timestamp("2026-01-01 00:05", tz="UTC"))
+        self.assertEqual(
+            model.dd.pair_dict[PAIR]["extras"][CALIBRATION_START_KEY],
+            "2026-01-01T00:05:00+00:00",
+        )
+
+    # ------------------------------------------------- produced marker and private column
+
+    def _model_with_appended_history(
+        self, saved_dates: list[str]
+    ) -> tuple[QuickAdapterRegressorV3, SimpleNamespace]:
+        frame = history(saved_dates, [False] * len(saved_dates), values=[0.1] * len(saved_dates))
+        dd = drawer()
+        dd.historic_predictions[PAIR] = frame
+        dd.model_return_values[PAIR] = frame.tail(1).reset_index(drop=True)
+        model = regressor(dd=dd)
+        return model, dd
+
+    def _strategy_frame(self) -> pd.DataFrame:
+        return pd.DataFrame(
+            {
+                "date": pd.to_datetime(["2026-01-01 00:05"], utc=True),
+                "high": [101.0],
+                "low": [99.0],
+                "close": [100.0],
+            }
+        )
+
+    def test_marker_is_set_on_the_current_candle_and_its_return_mirror_only(self):
+        model, dd = self._model_with_appended_history(
+            ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"]
+        )
+        dk = kitchen()
+        dk.return_dataframe = dd.historic_predictions[PAIR].copy()
+
+        def native_overwrite(self, dataframe, dk, pair, trained_timestamp):
+            current = pd.Timestamp(dataframe["date"].iloc[-1])
+            frame = dd.historic_predictions[pair]
+            frame.at[frame.index[-1], "date_pred"] = current
+            dd.model_return_values[pair] = frame.tail(1).reset_index(drop=True)
+            dk.return_dataframe = frame.tail(1).reset_index(drop=True)
+
+        with mock.patch.object(
+            IFreqaiModel,
+            "build_strategy_return_arrays",
+            autospec=True,
+            side_effect=native_overwrite,
+        ):
+            model.build_strategy_return_arrays(self._strategy_frame(), dk, PAIR, 0)
+
+        self.assertEqual(
+            dd.historic_predictions[PAIR][_PRODUCED_PREDICTION_COLUMN].tolist(),
+            [False, False, True],
+        )
+        self.assertEqual(dd.model_return_values[PAIR][_PRODUCED_PREDICTION_COLUMN].tolist(), [True])
+        self.assertNotIn(_PRODUCED_PREDICTION_COLUMN, dk.return_dataframe)
+        self.assertEqual(model._calibration_current_candles, {})
+
+    def test_no_marker_when_the_saved_candle_predates_the_strategy_frame(self):
+        model, dd = self._model_with_appended_history(
+            ["2026-01-01 00:50", "2026-01-01 00:55", "2026-01-01 01:00"]
+        )
+        dk = kitchen()
+        dk.return_dataframe = dd.historic_predictions[PAIR].copy()
+
+        def stale_append(self, dataframe, dk, pair, trained_timestamp):
+            dk.return_dataframe = dd.historic_predictions[pair].tail(1).reset_index(drop=True)
+
+        with mock.patch.object(
+            IFreqaiModel, "build_strategy_return_arrays", autospec=True, side_effect=stale_append
+        ):
+            model.build_strategy_return_arrays(self._strategy_frame(), dk, PAIR, 0)
+
+        self.assertEqual(
+            dd.historic_predictions[PAIR][_PRODUCED_PREDICTION_COLUMN].tolist(), [False] * 3
+        )
+        self.assertEqual(
+            dd.model_return_values[PAIR][_PRODUCED_PREDICTION_COLUMN].tolist(), [False]
+        )
+        self.assertNotIn(_PRODUCED_PREDICTION_COLUMN, dk.return_dataframe)
+
+    def test_the_current_candle_is_released_even_when_the_native_append_fails(self):
+        model, dd = self._model_with_appended_history(
+            ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"]
+        )
+        dk = kitchen()
+        dk.return_dataframe = dd.historic_predictions[PAIR].copy()
+
+        with (
+            mock.patch.object(
+                IFreqaiModel,
+                "build_strategy_return_arrays",
+                autospec=True,
+                side_effect=RuntimeError("drawer unavailable"),
+            ),
+            self.assertRaisesRegex(RuntimeError, "drawer unavailable"),
+        ):
+            model.build_strategy_return_arrays(self._strategy_frame(), dk, PAIR, 0)
+
+        self.assertEqual(model._calibration_current_candles, {})
+        self.assertEqual(
+            dd.historic_predictions[PAIR][_PRODUCED_PREDICTION_COLUMN].tolist(), [False] * 3
+        )
+
+    def test_return_dataframe_is_always_stripped_of_the_private_column(self):
+        model, _ = self._model_with_appended_history(
+            ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"]
+        )
+        dk = kitchen()
+        dk.return_dataframe = pd.DataFrame({_PRODUCED_PREDICTION_COLUMN: [True], LABEL: [0.0]})
+
+        with mock.patch.object(IFreqaiModel, "build_strategy_return_arrays", autospec=True):
+            model.build_strategy_return_arrays(self._strategy_frame(), dk, PAIR, 0)
+
+        self.assertNotIn(_PRODUCED_PREDICTION_COLUMN, dk.return_dataframe)
+        self.assertIn(LABEL, dk.return_dataframe)
+
+    def test_bootstrap_history_gains_an_all_false_produced_column(self):
+        frame = history(["2026-01-01 00:00", "2026-01-01 00:05"], [False, False])
+        dd = drawer()
+        dd.historic_predictions[PAIR] = frame
+        model = regressor(dd=dd)
+        strat_df = pd.DataFrame(
+            {
+                "date": pd.to_datetime(["2026-01-01 00:00", "2026-01-01 00:05"], utc=True),
+                "high": [101.0, 101.0],
+                "low": [99.0, 99.0],
+                "close": [100.0, 100.0],
+            }
+        )
+        bootstrap = pd.DataFrame({LABEL: [0.0, 0.0]})
+        dk = kitchen()
+        dk.data["extra_returns_per_train"] = {}
+
+        with mock.patch.object(
+            IFreqaiModel,
+            "set_initial_historic_predictions",
+            autospec=True,
+            side_effect=lambda self, *args: self.dd.historic_predictions.__setitem__(
+                PAIR, bootstrap.copy()
+            ),
+        ):
+            model.set_initial_historic_predictions(bootstrap, dk, PAIR, strat_df)
+
+        self.assertEqual(
+            dd.historic_predictions[PAIR][_PRODUCED_PREDICTION_COLUMN].tolist(),
+            [False, False],
+        )
+
+    # ---------------------------------------------------------------- DI cutoff sentinels
+
+    def test_cold_pair_uses_sentinels_outside_the_normalized_label_range(self):
+        # One produced observation against a two-candle budget: warmup is not complete,
+        # so no threshold may be derived from an unobserved distribution.
+        model, dk, _ = calibrated(candles=1, budget=2)
+        extra = dk.data["extra_returns_per_train"]
+
+        model.fit_live_predictions(dk, PAIR)
+
+        self.assertEqual(extra[f"{LABEL}_minima_threshold"], -2.0)
+        self.assertEqual(extra[f"{LABEL}_maxima_threshold"], 2.0)
+        self.assertEqual(extra["DI_value_param1"], 0.0)
+        self.assertEqual(extra["DI_value_param2"], 0.0)
+        self.assertEqual(extra["DI_value_param3"], 0.0)
+        self.assertEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
+        # Outside [-1, 1] on purpose: a cold pair has no observed distribution, so a
+        # sentinel inside the reachable range would manufacture an extremum threshold
+        # from nothing and fire on the very first candle.
+        self.assertLess(extra[f"{LABEL}_minima_threshold"], LABEL_RANGE[0])
+        self.assertGreater(extra[f"{LABEL}_maxima_threshold"], LABEL_RANGE[1])
+        self.assertEqual(LABEL_RANGE, (-1.0, 1.0))
+        self.assertGreater(DI_CUTOFF_DEFAULT, LABEL_RANGE[1])
+        self.assertTrue(np.isfinite(extra["DI_cutoff"]))
+
+    def test_degenerate_di_fit_substitutes_the_declared_default_instead_of_nan(self):
+        model, dk, _ = calibrated(candles=3, di=[2.5, 2.5, 2.5])
+        extra = dk.data["extra_returns_per_train"]
+
+        model.fit_live_predictions(dk, PAIR)
+
+        self.assertEqual(extra["DI_value_param1"], 0.0)
+        self.assertEqual(extra["DI_value_param2"], 0.0)
+        self.assertEqual(extra["DI_value_param3"], 0.0)
+        self.assertTrue(np.isfinite(extra["DI_cutoff"]))
+        self.assertEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
+
+    def test_a_non_finite_ppf_is_replaced_even_when_the_fit_degrades(self):
+        model, dk, _ = calibrated(candles=3, di=[1.0, 2.0, 3.0])
+        extra = dk.data["extra_returns_per_train"]
+
+        with mock.patch("scipy.stats.weibull_min.ppf", return_value=float("nan")) as ppf:
+            model.fit_live_predictions(dk, PAIR)
+
+        ppf.assert_called_once()
+        self.assertEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
+        self.assertFalse(pd.isna(extra["DI_cutoff"]))
+
+    def test_a_healthy_di_sample_keeps_its_fitted_cutoff(self):
+        model, dk, _ = calibrated(candles=4, di=[0.5, 1.25, 1.75, 2.5])
+        extra = dk.data["extra_returns_per_train"]
+
+        model.fit_live_predictions(dk, PAIR)
+
+        # The fallback is not taken: the published cutoff is the Weibull quantile of the
+        # parameters the same call recorded, so the substitution is the only divergence.
+        self.assertNotEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
+        self.assertTrue(np.isfinite(extra["DI_cutoff"]))
+        np.testing.assert_allclose(
+            extra["DI_cutoff"],
+            sp.stats.weibull_min.ppf(
+                0.999,
+                extra["DI_value_param1"],
+                extra["DI_value_param2"],
+                extra["DI_value_param3"],
+            ),
+            rtol=1e-12,
+            atol=0.0,
+        )
+
+    def test_missing_and_non_positive_di_values_never_yield_a_nan_cutoff(self):
+        for di in ([np.nan] * 4, [0.0, 0.0, 0.0, 0.0], [-1.0, -2.0, -3.0, -4.0]):
+            with self.subTest(di_values=di):
+                model, dk, _ = calibrated(candles=4, di=di)
+                extra = dk.data["extra_returns_per_train"]
+
+                model.fit_live_predictions(dk, PAIR)
+
+                self.assertEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
+
+    def test_a_fit_without_di_values_uses_the_default_cutoff(self):
+        model, dk, _ = calibrated(candles=2)
+        extra = dk.data["extra_returns_per_train"]
+
+        model.fit_live_predictions(dk, PAIR)
+
+        self.assertEqual(extra["DI_cutoff"], DI_CUTOFF_DEFAULT)
+        self.assertEqual(dk.data["DI_value_mean"], 0.0)
+        self.assertEqual(dk.data["DI_value_std"], 0.0)
+
+    # ------------------------------------------------------------ holdout rmse validation
+
+    def test_validate_value_maps_every_non_finite_input_to_none(self):
+        for value in (
+            float("nan"),
+            float("inf"),
+            float("-inf"),
+            np.nan,
+            np.inf,
+            None,
+            "0.5",
+            [1.0],
+        ):
+            with self.subTest(value=value):
+                self.assertIsNone(QuickAdapterRegressorV3.optuna_validate_value(value))
+
+    def test_validate_value_passes_finite_numbers_through_unchanged(self):
+        self.assertEqual(QuickAdapterRegressorV3.optuna_validate_value(3), 3)
+        self.assertEqual(QuickAdapterRegressorV3.optuna_validate_value(-1.25), -1.25)
+        self.assertEqual(QuickAdapterRegressorV3.optuna_validate_value(0.0), 0.0)
+
+    def test_holdout_rmse_is_infinite_rather_than_a_fabricated_score(self):
+        # (in-memory score, persisted replay column, pair already fitted this session)
+        for holdout, replay, session_fitted in (
+            ({PAIR: float("inf")}, None, True),
+            ({}, None, False),
+            ({PAIR: float("inf")}, [float("nan"), float("nan")], False),
+        ):
+            with self.subTest(holdout=holdout, replay=replay, session_fitted=session_fitted):
+                model, dk, _ = calibrated(candles=2, holdout=replay)
+                model._holdout_rmse = dict(holdout)
+                if not session_fitted:
+                    model._session_fitted_pairs = set()
+                extra = dk.data["extra_returns_per_train"]
+
+                model.fit_live_predictions(dk, PAIR)
+
+                self.assertTrue(np.isposinf(extra["holdout_rmse"]))
+                self.assertIsNone(
+                    QuickAdapterRegressorV3.optuna_validate_value(extra["holdout_rmse"])
+                )
+
+    def test_a_replayed_produced_holdout_score_is_used_when_present(self):
+        model, dk, _ = calibrated(candles=2, holdout=[np.nan, 1.75])
+        model._holdout_rmse = {PAIR: float("inf")}
+        model._session_fitted_pairs = set()
+        extra = dk.data["extra_returns_per_train"]
+
+        model.fit_live_predictions(dk, PAIR)
+
+        self.assertEqual(extra["holdout_rmse"], 1.75)
+
+    def test_an_unfitted_pair_with_no_replay_column_reports_infinite(self):
+        model, dk, _ = calibrated(candles=2)
+        model._holdout_rmse = {}
+        model._session_fitted_pairs = set()
+        extra = dk.data["extra_returns_per_train"]
+
+        model.fit_live_predictions(dk, PAIR)
+
+        self.assertTrue(np.isposinf(extra["holdout_rmse"]))
+
+    # --------------------------------------------------------------- optuna throttling
+
+    def _throttled(self, budget: int, pool: list[int] | None = None) -> QuickAdapterRegressorV3:
+        model = regressor(_label_frequency_candles=3)
+        model._optuna_label_candle_pool = [3, 4, 5] if pool is None else list(pool)
+        model._optuna_label_candle_pool_full_cache = {3: [3, 4, 5]}
+        model._optuna_label_shuffle_rng = random.Random(7)
+        model._optuna_label_candle = {PAIR: budget}
+        return model
+
+    def _tick(self, model: QuickAdapterRegressorV3, candle: int, fired: list[int]) -> None:
+        model.optuna_throttle_callback(
+            PAIR, _OPTUNA_NAMESPACES.label, lambda candle=candle: fired.append(candle)
+        )
+
+    def test_throttle_fires_only_once_the_candle_budget_is_spent(self):
+        model = self._throttled(2, [3, 4, 5])
+        fired: list[int] = []
+
+        for candle in range(4):
+            self._tick(model, candle, fired)
+
+        # Two candles of budget: the first is throttled, the second emits. A fabricated
+        # per-candle emission would have fired on all four.
+        self.assertEqual(fired, [1])
+        self.assertEqual(model._optuna_label_candles[PAIR], 2)
+
+    def test_throttle_rearms_a_fresh_budget_after_emitting(self):
+        model = self._throttled(1, [5])
+        fired: list[int] = []
+
+        for candle in range(6):
+            self._tick(model, candle, fired)
+
+        # Emitted on candle 0, re-armed to 5, so the next emission is candle 5 - the
+        # budget is re-spent rather than reset to zero (which would fire every candle).
+        self.assertEqual(fired, [0, 5])
+        self.assertEqual(model._optuna_label_candles[PAIR], 0)
+        self.assertEqual(model._optuna_label_candle[PAIR], 4)
+
+    def test_a_raising_callback_is_logged_and_the_following_candle_still_emits(self):
+        model = self._throttled(1, [5])
+        fired: list[int] = []
+
+        def callback(candle: int) -> None:
+            if candle == 0:
+                raise RuntimeError("optuna exploded")
+            fired.append(candle)
+
+        with mock.patch.object(regressor_module.logger, "exception") as logged:
+            for candle in range(6):
+                model.optuna_throttle_callback(
+                    PAIR,
+                    _OPTUNA_NAMESPACES.label,
+                    lambda candle=candle: callback(candle),
+                )
+
+        logged.assert_called_once()
+        self.assertEqual(fired, [5])
+        self.assertEqual(model._optuna_label_candles[PAIR], 0)
+
+    def test_incremented_pairs_reset_once_every_pair_has_been_counted(self):
+        model = self._throttled(5)
+        model.pairs = [PAIR, "ETH/USDT"]
+        model._optuna_label_candle = {PAIR: 5, "ETH/USDT": 5}
+        model._optuna_label_candles = {PAIR: 0, "ETH/USDT": 0}
+
+        model.optuna_throttle_callback(PAIR, _OPTUNA_NAMESPACES.label, lambda: None)
+        self.assertEqual(model._optuna_label_incremented_pairs, [PAIR])
+
+        model.optuna_throttle_callback("ETH/USDT", _OPTUNA_NAMESPACES.label, lambda: None)
+        self.assertEqual(model._optuna_label_incremented_pairs, [])
+
+    def test_throttle_rejects_a_foreign_namespace_and_a_non_callable(self):
+        model = self._throttled(5)
+
+        with self.assertRaisesRegex(ValueError, "namespace"):
+            model.optuna_throttle_callback(PAIR, _OPTUNA_NAMESPACES.hp, lambda: None)
+
+        with self.assertRaisesRegex(ValueError, "must be callable"):
+            model.optuna_throttle_callback(PAIR, _OPTUNA_NAMESPACES.label, "not callable")
+
+        self.assertEqual(model._optuna_label_candles[PAIR], 0)
+
+    # ------------------------------------------------------------ warn-once known-at-none
+
+    def test_known_at_none_warns_once_per_pair_and_context(self):
+        with mock.patch.object(regressor_module.logger, "info") as logged:
+            _log_known_at_none_once(PAIR, "label")
+            _log_known_at_none_once(PAIR, "label")
+            _log_known_at_none_once(PAIR, "label_weight")
+            _log_known_at_none_once("ETH/USDT", "label")
+
+        self.assertEqual(logged.call_count, 3)
+        self.assertEqual(
+            sorted(regressor_module._KNOWN_AT_NONE_LOGGED),
+            sorted({(PAIR, "label"), (PAIR, "label_weight"), ("ETH/USDT", "label")}),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_regressor_selection.py b/quickadapter/tests/test_regressor_selection.py
new file mode 100644 (file)
index 0000000..e463c15
--- /dev/null
@@ -0,0 +1,2109 @@
+"""Extrema/threshold surfaces, distance/TOPSIS algebra and multi-objective trial selection; requires the Freqtrade QA image."""
+
+import unittest
+
+import numpy as np
+import optuna
+import pandas as pd
+from EnumErrors import enum_error_message
+from LabelTransformer import EXTREMA_SELECTION_METHODS, SKIMAGE_THRESHOLD_METHODS
+from qa_support import REGRESSOR_MODULE, QaTestCase
+
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import QuickAdapterRegressorV3
+
+# The module logger is named after the module's __name__, so the shared path constant
+# is also the logger name assertLogs/assertNoLogs needs.
+LOGGER_NAME = REGRESSOR_MODULE
+
+# §5.6: the atol constant is scoped to constant-column detection on the normalised
+# objective matrix, so it is the tolerance for that family and for TOPSIS scores.
+NORMALIZED_ATOL = QuickAdapterRegressorV3._NON_CONSTANT_OBJECTIVE_ATOL
+# §5.6: metric distances and pairwise distance sums are O(1)-O(k) quantities.
+DISTANCE_RTOL = 1e-12
+
+MINIMIZE = optuna.study.StudyDirection.MINIMIZE
+MAXIMIZE = optuna.study.StudyDirection.MAXIMIZE
+
+RAISE = "raise"
+WARN = "warn"
+NONE = "none"
+
+UNIT_WEIGHTS = np.ones(2)
+#: A three-trial front whose two objectives sum to a constant per row, so the
+#: distance to the all-ones ideal and to the all-zeros anti-ideal add to a fixed total.
+FRONT = np.array([[0.0, 0.0], [1.0, 0.0], [0.5, 0.5]])
+IDEAL = np.ones(2)
+ANTI_IDEAL = np.zeros(2)
+IDEAL_2D = np.ones((1, 2))
+#: Power means 0, 1 and 2 against a reference of 1: below, at, and above the ideal.
+BELOW_IDEAL = np.array([[0.0, 0.0]])
+AT_IDEAL = np.array([[1.0, 1.0]])
+ABOVE_IDEAL = np.array([[2.0, 2.0]])
+#: A row whose two objectives are unequal, so its power mean genuinely depends on p.
+ASYMMETRIC_ROW = np.array([[0.25, 0.75]])
+
+
+def front() -> np.ndarray:
+    """Return a three-trial, two-objective normalised objective matrix.
+
+    The rows are the all-zeros trial, the all-ones trial and a midpoint trial, so the
+    aggregate scores can be read straight off their distance to the all-ones ideal.
+    """
+    return np.array([[0.0, 0.0], [1.0, 1.0], [0.5, 0.5]])
+
+
+def raw_front() -> np.ndarray:
+    """Return the un-normalised values `front()` is the MINIMIZE normalisation of."""
+    return np.array([[9.0, 9.0], [1.0, 1.0], [5.0, 5.0]])
+
+
+def unique_rows(n_rows: int) -> np.ndarray:
+    """Return a two-column matrix of `n_rows` distinct rows, no RNG involved."""
+    return np.arange(n_rows * 2, dtype=float).reshape(n_rows, 2)
+
+
+def repeated_rows(n_rows: int, n_distinct: int) -> np.ndarray:
+    """Return a front of `n_rows` rows cycling through `n_distinct` distinct points."""
+    distinct = np.arange(n_distinct * 2, dtype=float).reshape(n_distinct, 2)
+    return distinct[np.arange(n_rows) % n_distinct]
+
+
+def frozen_trial(number: int) -> optuna.trial.FrozenTrial:
+    """Build a completed FrozenTrial carrying only its trial number, which is all selection reads."""
+    return optuna.trial.FrozenTrial(
+        number=number,
+        state=optuna.trial.TrialState.COMPLETE,
+        value=float(number),
+        datetime_start=None,
+        datetime_complete=None,
+        params={},
+        distributions={},
+        user_attrs={},
+        system_attrs={},
+        intermediate_values={},
+        trial_id=number,
+    )
+
+
+def trials(*numbers: int) -> list[optuna.trial.FrozenTrial]:
+    return [frozen_trial(number) for number in numbers]
+
+
+def threshold_spy() -> tuple[list, object]:
+    """Return a call log and a threshold function that records its input and returns a marker."""
+    calls: list[np.ndarray] = []
+
+    def spy(values: np.ndarray) -> float:
+        calls.append(np.array(values, copy=True))
+        return 99.0
+
+    return calls, spy
+
+
+class RegressorSelectionTest(QaTestCase):
+    # ------------------------------------------------------------------ category and defaults
+
+    def test_every_selection_method_maps_to_its_category(self):
+        expected = {
+            "compromise_programming": "distance",
+            "topsis": "distance",
+            "kmeans": "cluster",
+            "kmeans2": "cluster",
+            "kmedoids": "cluster",
+            "knn": "density",
+            "medoid": "density",
+        }
+        for method, category in expected.items():
+            with self.subTest(method=method):
+                self.assertEqual(QuickAdapterRegressorV3._get_selection_category(method), category)
+        grouped = {
+            method: category
+            for category, methods in QuickAdapterRegressorV3._SELECTION_CATEGORIES.items()
+            for method in methods
+        }
+        self.assertEqual(grouped, expected)
+
+    def test_an_unknown_selection_method_has_no_category(self):
+        self.assertIsNone(QuickAdapterRegressorV3._get_selection_category("kmedoid"))
+
+    def test_only_minkowski_and_power_mean_carry_a_p_order_default(self):
+        # The other fifteen metrics are either SciPy-native or a fixed named power mean,
+        # so a bare p-order has nothing to resolve to.
+        expected = {"minkowski": 2.0, "power_mean": 1.0}
+        for metric in QuickAdapterRegressorV3._DISTANCE_METRICS:
+            with self.subTest(metric=metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._get_label_p_order_default(metric), expected.get(metric)
+                )
+
+    def test_density_methods_default_to_the_scipy_compatible_metric_of_their_route(self):
+        self.assertEqual(
+            QuickAdapterRegressorV3._get_label_density_metric_default("knn"),
+            QuickAdapterRegressorV3._METRIC_MINKOWSKI,
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._get_label_density_metric_default("medoid"),
+            QuickAdapterRegressorV3._METRIC_EUCLIDEAN,
+        )
+
+    def test_a_density_method_outside_the_known_set_has_no_metric_default(self):
+        self.assertIsNone(QuickAdapterRegressorV3._get_label_density_metric_default("kde"))
+
+    def test_only_the_two_aggregations_that_reduce_over_neighbours_carry_a_param_default(self):
+        # min and max are order statistics and take no parameter at all.
+        self.assertEqual(
+            QuickAdapterRegressorV3._get_label_density_aggregation_param_default("power_mean"), 1.0
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._get_label_density_aggregation_param_default("quantile"), 0.5
+        )
+        for aggregation in ("min", "max"):
+            with self.subTest(aggregation=aggregation):
+                self.assertIsNone(
+                    QuickAdapterRegressorV3._get_label_density_aggregation_param_default(
+                        aggregation
+                    )
+                )
+
+    # ------------------------------------------------------------------ objective normalisation
+
+    def test_minimise_maps_the_worst_value_to_zero_and_the_best_to_one(self):
+        raw = np.array([[1.0, 10.0], [3.0, 20.0], [5.0, 30.0]])
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._normalize_objective_values(raw, [MINIMIZE, MINIMIZE]),
+            np.array([[1.0, 1.0], [0.5, 0.5], [0.0, 0.0]]),
+            rtol=0.0,
+            atol=NORMALIZED_ATOL,
+        )
+
+    def test_maximise_is_the_mirror_image_of_minimise(self):
+        raw = np.array([[1.0, 10.0], [3.0, 20.0], [5.0, 30.0]])
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._normalize_objective_values(raw, [MAXIMIZE, MAXIMIZE]),
+            np.array([[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]),
+            rtol=0.0,
+            atol=NORMALIZED_ATOL,
+        )
+
+    def test_each_objective_is_scaled_against_its_own_range(self):
+        # A shared range would compress the narrow column; per-column scaling does not.
+        raw = np.array([[0.0, 100.0], [1.0, 200.0], [2.0, 900.0]])
+        result = QuickAdapterRegressorV3._normalize_objective_values(raw, [MINIMIZE, MINIMIZE])
+        np.testing.assert_allclose(
+            result[:, 0], np.array([1.0, 0.5, 0.0]), rtol=0.0, atol=NORMALIZED_ATOL
+        )
+        np.testing.assert_allclose(
+            result[:, 1], np.array([1.0, 0.875, 0.0]), rtol=0.0, atol=NORMALIZED_ATOL
+        )
+
+    def test_each_objective_resolves_its_own_direction(self):
+        raw = np.array([[1.0], [3.0], [5.0]])
+        result = QuickAdapterRegressorV3._normalize_objective_values(raw, [MAXIMIZE])
+        np.testing.assert_allclose(
+            result[:, 0], np.array([0.0, 0.5, 1.0]), rtol=0.0, atol=NORMALIZED_ATOL
+        )
+
+    def test_a_positive_infinity_is_the_worst_value_when_minimising(self):
+        raw = np.array([[1.0], [3.0], [5.0], [np.inf]])
+        result = QuickAdapterRegressorV3._normalize_objective_values(raw, [MINIMIZE])
+        self.assertAlmostEqual(float(result[-1, 0]), 0.0, places=9)
+
+    def test_a_negative_infinity_is_the_worst_value_when_maximising(self):
+        raw = np.array([[1.0], [3.0], [5.0], [-np.inf]])
+        result = QuickAdapterRegressorV3._normalize_objective_values(raw, [MAXIMIZE])
+        self.assertAlmostEqual(float(result[-1, 0]), 0.0, places=9)
+
+    def test_an_infinite_lands_on_the_end_of_the_unit_interval_its_direction_puts_it_at(self):
+        raw = np.array([[1.0], [3.0], [5.0], [np.inf], [-np.inf]])
+        for direction, positive_infinity, negative_infinity in (
+            (MINIMIZE, 0.0, 1.0),
+            (MAXIMIZE, 1.0, 0.0),
+        ):
+            with self.subTest(direction=direction):
+                result = QuickAdapterRegressorV3._normalize_objective_values(raw, [direction])
+                self.assertAlmostEqual(float(result[3, 0]), positive_infinity, places=9)
+                self.assertAlmostEqual(float(result[4, 0]), negative_infinity, places=9)
+                # The finite samples are scaled between the two infinities, so the
+                # direction's worst infinity is the worst score of the column and no
+                # finite sample escapes the unit interval.
+                low, high = sorted((positive_infinity, negative_infinity))
+                self.assertTrue(np.all(result[:3, 0] >= low))
+                self.assertTrue(np.all(result[:3, 0] <= high))
+
+    def test_a_degenerate_range_with_no_infinity_resolves_to_the_midpoint(self):
+        raw = np.array([[2.0], [2.0], [2.0]])
+        for direction in (MINIMIZE, MAXIMIZE):
+            with self.subTest(direction=direction):
+                result = QuickAdapterRegressorV3._normalize_objective_values(raw, [direction])
+                np.testing.assert_allclose(
+                    result, np.full((3, 1), 0.5), rtol=0.0, atol=NORMALIZED_ATOL
+                )
+
+    def test_a_degenerate_range_below_a_positive_infinity_resolves_away_from_it(self):
+        # With no spread to scale, the finite samples take the value that keeps the
+        # column ordered: better than +inf under MAXIMIZE, worse under MINIMIZE.
+        raw = np.array([[1.0], [1.0], [np.inf]])
+        self.assertAlmostEqual(
+            float(QuickAdapterRegressorV3._normalize_objective_values(raw, [MINIMIZE])[0, 0]),
+            1.0,
+            places=9,
+        )
+        self.assertAlmostEqual(
+            float(QuickAdapterRegressorV3._normalize_objective_values(raw, [MAXIMIZE])[0, 0]),
+            0.0,
+            places=9,
+        )
+
+    def test_a_degenerate_range_above_a_negative_infinity_resolves_away_from_it(self):
+        raw = np.array([[1.0], [1.0], [-np.inf]])
+        self.assertAlmostEqual(
+            float(QuickAdapterRegressorV3._normalize_objective_values(raw, [MINIMIZE])[0, 0]),
+            0.0,
+            places=9,
+        )
+        self.assertAlmostEqual(
+            float(QuickAdapterRegressorV3._normalize_objective_values(raw, [MAXIMIZE])[0, 0]),
+            1.0,
+            places=9,
+        )
+
+    def test_a_degenerate_range_between_both_infinities_resolves_to_the_midpoint(self):
+        raw = np.array([[1.0], [1.0], [np.inf], [-np.inf]])
+        for direction in (MINIMIZE, MAXIMIZE):
+            with self.subTest(direction=direction):
+                result = QuickAdapterRegressorV3._normalize_objective_values(raw, [direction])
+                np.testing.assert_allclose(
+                    result[:2, 0], np.array([0.5, 0.5]), rtol=0.0, atol=NORMALIZED_ATOL
+                )
+                # The infinities keep their own ends: the pair still spans the unit interval.
+                self.assertAlmostEqual(float(result[2:].max()), 1.0, places=9)
+                self.assertAlmostEqual(float(result[2:].min()), 0.0, places=9)
+
+    def test_a_range_below_ten_eps_is_treated_as_degenerate(self):
+        # 10 * finfo.eps, not finfo.eps: the guard exists so float noise on an
+        # otherwise-constant column cannot produce a meaningless [0,1] scale. A range
+        # of 5 * eps is degenerate at 10 * eps and a real spread at 1 * eps, so both
+        # sides of the declared threshold are pinned. The column starts at 0.0 so the
+        # stored range is exactly the delta under test.
+        eps = np.finfo(float).eps
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._normalize_objective_values(
+                np.array([[0.0], [5 * eps]]), [MINIMIZE]
+            ),
+            np.array([[0.5], [0.5]]),
+            rtol=0.0,
+            atol=NORMALIZED_ATOL,
+        )
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._normalize_objective_values(
+                np.array([[0.0], [100 * eps]]), [MINIMIZE]
+            ),
+            np.array([[1.0], [0.0]]),
+            rtol=0.0,
+            atol=NORMALIZED_ATOL,
+        )
+
+    def test_a_range_too_wide_to_subtract_is_refused_rather_than_scaled_to_nan(self):
+        # max - min overflows to inf for a column spanning more than float64 can hold,
+        # so the scaled value is inf/inf = nan. Refusing beats ranking on a NaN score.
+        for direction in (MINIMIZE, MAXIMIZE):
+            with (
+                self.subTest(direction=direction),
+                self.assertRaisesRegex(
+                    ValueError, "must contain only finite values after normalization"
+                ),
+            ):
+                QuickAdapterRegressorV3._normalize_objective_values(
+                    np.array([[-1e308], [0.0], [1e308]]), [direction]
+                )
+
+    def test_a_column_with_no_finite_sample_keeps_only_its_infinite_endpoints(self):
+        raw = np.array([[np.inf], [-np.inf]])
+        for direction, expected in ((MINIMIZE, [0.0, 1.0]), (MAXIMIZE, [1.0, 0.0])):
+            with self.subTest(direction=direction):
+                result = QuickAdapterRegressorV3._normalize_objective_values(raw, [direction])
+                np.testing.assert_allclose(
+                    result[:, 0], np.array(expected), rtol=0.0, atol=NORMALIZED_ATOL
+                )
+
+    def test_a_failed_trial_is_normalised_as_the_worst_value_rather_than_rejected(self):
+        # NaN is neither finite nor an infinity, so it keeps the zeros the output was
+        # allocated with. A single non-finite objective must not void a whole trial.
+        raw = np.array([[np.nan, 1.0], [2.0, 2.0]])
+        result = QuickAdapterRegressorV3._normalize_objective_values(raw, [MINIMIZE, MINIMIZE])
+        self.assertAlmostEqual(float(result[0, 0]), 0.0, places=9)
+        self.assertTrue(np.all(np.isfinite(result)))
+
+    def test_a_front_that_is_not_two_dimensional_is_rejected(self):
+        with self.assertRaisesRegex(ValueError, "must be 2-dimensional"):
+            QuickAdapterRegressorV3._normalize_objective_values(np.array([1.0, 2.0]), [MINIMIZE])
+
+    def test_an_empty_front_or_an_empty_objective_axis_is_rejected(self):
+        for raw, directions in (
+            (np.zeros((0, 2)), [MINIMIZE, MINIMIZE]),
+            (np.zeros((2, 0)), []),
+        ):
+            with (
+                self.subTest(shape=raw.shape),
+                self.assertRaisesRegex(ValueError, "at least one sample and one objective"),
+            ):
+                QuickAdapterRegressorV3._normalize_objective_values(raw, directions)
+
+    def test_a_direction_count_that_disagrees_with_the_objective_count_is_rejected(self):
+        with self.assertRaisesRegex(ValueError, "must match number of objectives"):
+            QuickAdapterRegressorV3._normalize_objective_values(np.ones((2, 2)), [MINIMIZE])
+
+    # ------------------------------------------------------------------ constant-column detection
+
+    def test_the_returned_positions_are_column_indices_in_intp_dtype(self):
+        # A one-column non-constant matrix yields exactly one index, and assertEqual on a
+        # one-element ndarray passes silently instead of raising, so the list form is required.
+        indices = QuickAdapterRegressorV3._non_constant_objective_indices(np.array([[0.0], [1.0]]))
+        self.assertEqual(indices.dtype, np.dtype(np.intp))
+        self.assertEqual(indices.tolist(), [0])
+
+    def test_only_the_varying_columns_of_a_mixed_front_are_reported(self):
+        matrix = np.array(
+            [
+                [0.0, 0.5, 0.25, 0.75],
+                [0.0, 0.5, 0.75, 0.25],
+                [0.0, 0.5, 0.0, 1.0],
+            ]
+        )
+        np.testing.assert_array_equal(
+            QuickAdapterRegressorV3._non_constant_objective_indices(matrix), np.array([2, 3])
+        )
+
+    def test_an_entirely_constant_front_reports_no_columns_at_all(self):
+        indices = QuickAdapterRegressorV3._non_constant_objective_indices(np.ones((4, 3)))
+        self.assertEqual(indices.dtype, np.dtype(np.intp))
+        self.assertEqual(indices.tolist(), [])
+
+    def test_the_tolerance_is_absolute_so_a_ninth_decimal_column_is_constant(self):
+        # atol 1e-8 on a [0,1] column: a spread of 1e-9 is inside it, 1e-6 is outside.
+        inside = np.array([[0.0], [1e-9]])
+        outside = np.array([[0.0], [1e-6]])
+        self.assertEqual(
+            QuickAdapterRegressorV3._non_constant_objective_indices(inside).tolist(), []
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._non_constant_objective_indices(outside).tolist(), [0]
+        )
+
+    def test_a_ninth_decimal_spread_stays_reported_even_on_a_large_magnitude_column(self):
+        # rtol=0 is the point: numpy's default rtol=1e-5 would call this column constant
+        # because the column magnitude is 1e9, and silently drop a live objective.
+        column = np.array([[1e9], [1e9 + 1.0]])
+        self.assertTrue(np.allclose(column[:, 0], 1e9))
+        self.assertEqual(
+            QuickAdapterRegressorV3._non_constant_objective_indices(column).tolist(), [0]
+        )
+
+    def test_a_mid_magnitude_spread_is_not_absorbed_by_a_relative_tolerance(self):
+        # 1e-6 against a 0.5 reference is 2e-6 relatively: inside numpy's default rtol,
+        # outside the absolute 1e-8 the source declares.
+        column = np.array([[0.5], [0.5 + 1e-6]])
+        self.assertTrue(np.allclose(column[:, 0], 0.5))
+        self.assertEqual(
+            QuickAdapterRegressorV3._non_constant_objective_indices(column).tolist(), [0]
+        )
+
+    def test_the_reference_for_each_column_is_its_own_first_row(self):
+        # Row 0 differs from itself nowhere; a column is constant exactly when every
+        # later row matches row 0, not when the column's rows all differ from each other.
+        matrix = np.array([[0.25, 0.75], [0.25, 0.5], [0.25, 0.75]])
+        np.testing.assert_array_equal(
+            QuickAdapterRegressorV3._non_constant_objective_indices(matrix), np.array([1])
+        )
+
+    def test_a_non_finite_entry_is_refused_rather_than_treated_as_a_spread(self):
+        for bad in (np.array([1.0, 2.0]), np.array([[1.0], [np.nan]]), np.array([[1.0], [np.inf]])):
+            with self.subTest(bad=bad.tolist()), self.assertRaises(ValueError):
+                QuickAdapterRegressorV3._non_constant_objective_indices(bad)
+
+    # ------------------------------------------------------------------ distance family
+
+    def test_a_scipy_metric_branch_distances_to_the_ideal(self):
+        for metric, expected in (
+            ("euclidean", [np.sqrt(2.0), 1.0, np.sqrt(0.5)]),
+            ("cityblock", [2.0, 1.0, 1.0]),
+            ("chebyshev", [1.0, 1.0, 0.5]),
+            ("sqeuclidean", [2.0, 1.0, 0.5]),
+        ):
+            with self.subTest(metric=metric):
+                np.testing.assert_allclose(
+                    QuickAdapterRegressorV3._distance_to_reference(
+                        FRONT,
+                        IDEAL,
+                        metric,
+                        weights=UNIT_WEIGHTS,
+                        p=None,
+                        method="topsis",
+                        apply_abs=True,
+                        cdist_kwargs={},
+                    ),
+                    np.array(expected),
+                    rtol=DISTANCE_RTOL,
+                    atol=0.0,
+                )
+
+    def test_the_scipy_branch_hands_the_prepared_p_to_cdist(self):
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "minkowski", p=1.0, reference_matrix=unique_rows(4)
+        )
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._distance_to_reference(
+                FRONT,
+                IDEAL,
+                "minkowski",
+                weights=UNIT_WEIGHTS,
+                p=kwargs.get("p"),
+                method="topsis",
+                apply_abs=True,
+                cdist_kwargs=kwargs,
+            ),
+            QuickAdapterRegressorV3._distance_to_reference(
+                FRONT,
+                IDEAL,
+                "cityblock",
+                weights=UNIT_WEIGHTS,
+                p=None,
+                method="topsis",
+                apply_abs=True,
+                cdist_kwargs={},
+            ),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_the_power_mean_branch_is_signed_relative_to_the_reference(self):
+        # reference power mean minus row power mean: negative means the row sits above
+        # the reference, which is what makes a signed argmin meaningful downstream.
+        below = QuickAdapterRegressorV3._power_mean_distance(
+            BELOW_IDEAL, IDEAL, "power_mean", weights=UNIT_WEIGHTS, p=1.0
+        )
+        above = QuickAdapterRegressorV3._power_mean_distance(
+            ABOVE_IDEAL, IDEAL, "power_mean", weights=UNIT_WEIGHTS, p=1.0
+        )
+        at = QuickAdapterRegressorV3._power_mean_distance(
+            AT_IDEAL, IDEAL, "power_mean", weights=UNIT_WEIGHTS, p=1.0
+        )
+        self.assertGreater(float(below[0]), 0.0)
+        self.assertLess(float(above[0]), 0.0)
+        self.assertAlmostEqual(float(at[0]), 0.0, places=12)
+
+    def test_the_weighted_sum_branch_is_signed_in_the_same_direction(self):
+        below = QuickAdapterRegressorV3._weighted_sum_distance(
+            BELOW_IDEAL, IDEAL, weights=UNIT_WEIGHTS
+        )
+        above = QuickAdapterRegressorV3._weighted_sum_distance(
+            ABOVE_IDEAL, IDEAL, weights=UNIT_WEIGHTS
+        )
+        self.assertGreater(float(below[0]), 0.0)
+        self.assertLess(float(above[0]), 0.0)
+
+    def test_apply_abs_is_the_only_thing_separating_the_signed_and_magnitude_branches(self):
+        for metric, p in (("power_mean", 1.0), ("weighted_sum", None)):
+            with self.subTest(metric=metric):
+                stack = np.vstack([AT_IDEAL, ABOVE_IDEAL])
+                signed = QuickAdapterRegressorV3._distance_to_reference(
+                    stack,
+                    IDEAL,
+                    metric,
+                    weights=UNIT_WEIGHTS,
+                    p=p,
+                    method="topsis",
+                    apply_abs=False,
+                    cdist_kwargs={},
+                )
+                magnitude = QuickAdapterRegressorV3._distance_to_reference(
+                    stack,
+                    IDEAL,
+                    metric,
+                    weights=UNIT_WEIGHTS,
+                    p=p,
+                    method="topsis",
+                    apply_abs=True,
+                    cdist_kwargs={},
+                )
+                self.assertLess(float(signed[1]), 0.0)
+                np.testing.assert_allclose(magnitude, np.abs(signed), rtol=DISTANCE_RTOL, atol=0.0)
+
+    def test_a_named_power_mean_metric_ignores_a_supplied_p(self):
+        # The five named means are fixed points of the map; only the generic
+        # "power_mean" metric reads the caller's p.
+        for metric, power in QuickAdapterRegressorV3._POWER_MEAN_MAP.items():
+            with self.subTest(metric=metric):
+                # The map's value is the order the named mean stands for, and the metric
+                # route computes that order itself, so a supplied p must be ignored.
+                with_default = QuickAdapterRegressorV3._power_mean_distance(
+                    ASYMMETRIC_ROW, IDEAL, metric, weights=UNIT_WEIGHTS
+                )
+                with_other_p = QuickAdapterRegressorV3._power_mean_distance(
+                    ASYMMETRIC_ROW, IDEAL, metric, weights=UNIT_WEIGHTS, p=99.0
+                )
+                np.testing.assert_allclose(with_other_p, with_default, rtol=DISTANCE_RTOL, atol=0.0)
+                # And the order the map records is the one the named metric applies.
+                explicit = QuickAdapterRegressorV3._power_mean_distance(
+                    ASYMMETRIC_ROW, IDEAL, "power_mean", weights=UNIT_WEIGHTS, p=power
+                )
+                np.testing.assert_allclose(with_default, explicit, rtol=DISTANCE_RTOL, atol=0.0)
+        self.assertEqual(
+            QuickAdapterRegressorV3._POWER_MEAN_MAP,
+            {
+                "harmonic_mean": -1.0,
+                "geometric_mean": 0.0,
+                "arithmetic_mean": 1.0,
+                "quadratic_mean": 2.0,
+                "cubic_mean": 3.0,
+            },
+        )
+        # The generic metric does read p, so on an asymmetric row the two orders differ:
+        # a row of zeros has power mean zero at every p and would not separate them.
+        linear = QuickAdapterRegressorV3._power_mean_distance(
+            ASYMMETRIC_ROW, IDEAL, "power_mean", weights=UNIT_WEIGHTS, p=1.0
+        )
+        quadratic = QuickAdapterRegressorV3._power_mean_distance(
+            ASYMMETRIC_ROW, IDEAL, "power_mean", weights=UNIT_WEIGHTS, p=2.0
+        )
+        np.testing.assert_allclose(linear, np.array([0.5]), rtol=DISTANCE_RTOL, atol=0.0)
+        np.testing.assert_allclose(
+            quadratic, np.array([1.0 - np.sqrt(0.3125)]), rtol=DISTANCE_RTOL, atol=0.0
+        )
+
+    def test_a_zero_weighted_objective_does_not_contribute_to_the_power_mean(self):
+        # The observable contract: an objective the user weighted to zero is excluded
+        # from the aggregate rather than dragging it toward the reference. Only the
+        # second objective counts, so the mean is the second column on its own.
+        matrix = np.array([[1.0, 0.0], [0.0, 1.0]])
+        only_second = QuickAdapterRegressorV3._power_mean_distance(
+            matrix, IDEAL, "power_mean", weights=np.array([0.0, 1.0]), p=1.0
+        )
+        only_first = QuickAdapterRegressorV3._power_mean_distance(
+            matrix, IDEAL, "power_mean", weights=np.array([1.0, 0.0]), p=1.0
+        )
+        np.testing.assert_allclose(only_second, np.array([1.0, 0.0]), rtol=DISTANCE_RTOL, atol=0.0)
+        np.testing.assert_allclose(only_first, np.array([0.0, 1.0]), rtol=DISTANCE_RTOL, atol=0.0)
+        # With two objectives surviving the order still separates them, so a zero
+        # weight is not what flattens the mean against the reference.
+        three = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
+        surviving = np.array([0.0, 1.0, 1.0])
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._power_mean_distance(
+                three, np.ones(3), "power_mean", weights=surviving, p=1.0
+            ),
+            np.array([1.0, 0.5]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._power_mean_distance(
+                three, np.ones(3), "power_mean", weights=surviving, p=2.0
+            ),
+            np.array([1.0, 1.0 - np.sqrt(0.5)]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_the_power_mean_metric_falls_back_to_p_one_when_p_is_rejected(self):
+        for mode in (WARN, NONE):
+            with self.subTest(mode=mode):
+                rejected = QuickAdapterRegressorV3._power_mean_distance(
+                    BELOW_IDEAL, IDEAL, "power_mean", p=np.inf, mode=mode, weights=UNIT_WEIGHTS
+                )
+                fallback = QuickAdapterRegressorV3._power_mean_distance(
+                    BELOW_IDEAL, IDEAL, "power_mean", p=1.0, mode=mode, weights=UNIT_WEIGHTS
+                )
+                np.testing.assert_allclose(rejected, fallback, rtol=DISTANCE_RTOL, atol=0.0)
+
+    def test_an_unsupported_metric_names_the_calling_method_in_its_error(self):
+        for method in ("topsis", "compromise_programming"):
+            with (
+                self.subTest(method=method),
+                self.assertRaisesRegex(
+                    ValueError, rf"for {method}: supported values are euclidean, minkowski"
+                ),
+            ):
+                QuickAdapterRegressorV3._distance_to_reference(
+                    FRONT,
+                    IDEAL,
+                    "cosine",
+                    weights=UNIT_WEIGHTS,
+                    p=None,
+                    method=method,
+                    apply_abs=True,
+                    cdist_kwargs={},
+                )
+
+    def test_the_hellinger_branch_weighs_the_root_transformed_sqrt_difference(self):
+        matrix = np.array([[0.25, 0.25], [0.5, 0.5]])
+        result = QuickAdapterRegressorV3._hellinger_distance(matrix, IDEAL)
+        np.testing.assert_allclose(
+            result,
+            np.array(
+                [
+                    np.sqrt(2 * (0.5 - 1.0) ** 2) / np.sqrt(2.0),
+                    np.sqrt(2 * (np.sqrt(0.5) - 1.0) ** 2) / np.sqrt(2.0),
+                ]
+            ),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_the_shellinger_branch_inverts_the_variance_of_each_objective(self):
+        matrix = np.array([[0.25, 0.25], [0.5, 0.5]])
+        plain = QuickAdapterRegressorV3._hellinger_distance(matrix, IDEAL)
+        variances = np.var(np.sqrt(matrix), axis=0, ddof=1)
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._hellinger_distance(matrix, IDEAL, standardized=True),
+            plain / np.sqrt(variances),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_shellinger_refuses_a_front_with_a_constant_objective(self):
+        # The 1/variance weight is undefined there, so the whole metric is refused
+        # rather than silently producing an infinite distance.
+        matrix = np.array([[0.5, 0.25], [0.5, 0.75]])
+        with self.assertRaisesRegex(ValueError, "requires non-zero variance"):
+            QuickAdapterRegressorV3._hellinger_distance(matrix, IDEAL, standardized=True)
+
+    def test_explicit_weights_scale_the_hellinger_branch(self):
+        matrix = np.array([[0.25, 0.25], [0.5, 0.5]])
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._hellinger_distance(matrix, IDEAL, weights=np.full(2, 2.0)),
+            np.sqrt(2.0) * QuickAdapterRegressorV3._hellinger_distance(matrix, IDEAL),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    # ------------------------------------------------------------------ TOPSIS and compromise programming
+
+    def test_topsis_ranks_by_distance_to_the_ideal_so_the_lowest_score_wins(self):
+        # The score is d_ideal / (d_ideal + d_anti_ideal): it grows with distance from
+        # the ideal, and the selection caller takes the argmin. The all-ones trial sits
+        # on the ideal and scores 0; the all-zeros trial sits on the anti-ideal and scores 1.
+        for metric, extra in (
+            ("euclidean", {}),
+            ("cityblock", {}),
+            ("chebyshev", {}),
+            ("sqeuclidean", {}),
+            ("power_mean", {"p": 1.0}),
+        ):
+            with self.subTest(metric=metric):
+                scores = QuickAdapterRegressorV3._topsis_scores(
+                    front(), metric, distance_kwargs={}, **extra
+                )
+                np.testing.assert_allclose(
+                    scores, np.array([1.0, 0.0, 0.5]), rtol=0.0, atol=NORMALIZED_ATOL
+                )
+                self.assertEqual(int(np.argmin(scores)), 1)
+                self.assertEqual(int(np.argmax(scores)), 0)
+
+    def test_a_probability_metric_ranks_a_midpoint_trial_closer_to_the_anti_ideal(self):
+        # Hellinger compares root-transformed distributions, so a trial at the midpoint
+        # of every objective reads nearer the anti-ideal than a raw distance puts it.
+        scores = QuickAdapterRegressorV3._topsis_scores(front(), "hellinger", distance_kwargs={})
+        np.testing.assert_allclose(
+            scores, np.array([1.0, 0.0, 0.2928932188134525]), rtol=0.0, atol=NORMALIZED_ATOL
+        )
+
+    def test_topsis_stays_inside_the_unit_interval_for_every_distance_metric(self):
+        normalized = QuickAdapterRegressorV3._normalize_objective_values(
+            raw_front(), [MINIMIZE, MINIMIZE]
+        )
+        np.testing.assert_allclose(normalized, front(), rtol=0.0, atol=NORMALIZED_ATOL)
+        for metric in ("euclidean", "cityblock", "power_mean", "hellinger"):
+            with self.subTest(metric=metric):
+                kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+                    metric, p=1.0, reference_matrix=normalized
+                )
+                scores = QuickAdapterRegressorV3._topsis_scores(
+                    normalized, metric, distance_kwargs=kwargs, p=1.0
+                )
+                self.assertTrue(
+                    np.all((scores >= -NORMALIZED_ATOL) & (scores <= 1.0 + NORMALIZED_ATOL))
+                )
+
+    def test_a_zero_denominator_scores_one_half_rather_than_dividing(self):
+        # A fully-zeroed weight vector makes every trial equidistant from the ideal and
+        # the anti-ideal, so the ratio is 0/0. The declared answer is the neutral 0.5.
+        scores = QuickAdapterRegressorV3._topsis_scores(
+            front(), "weighted_sum", distance_kwargs={}, weights=np.zeros(2)
+        )
+        np.testing.assert_allclose(scores, np.full(3, 0.5), rtol=0.0, atol=NORMALIZED_ATOL)
+        self.assertTrue(np.all(np.isfinite(scores)))
+
+    def test_a_negligible_denominator_is_the_same_degenerate_case_as_an_exact_zero(self):
+        # np.isclose's 1e-8 draws the line: weights small enough that every trial's
+        # ideal-plus-anti-ideal total falls under it score a flat 0.5, while weights
+        # above it rank normally. Both sides are asserted so the branch cannot drift.
+        for weight, expected in ((1e-9, [0.5, 0.5, 0.5]), (1e-7, [1.0, 0.0, 0.5])):
+            with self.subTest(weight=weight):
+                scores = QuickAdapterRegressorV3._topsis_scores(
+                    front(), "weighted_sum", distance_kwargs={}, weights=np.full(2, weight)
+                )
+                np.testing.assert_allclose(
+                    scores, np.array(expected), rtol=0.0, atol=NORMALIZED_ATOL
+                )
+
+    def test_the_weighted_sum_branch_needs_no_weight_validation_to_reach_that_branch(self):
+        # weighted_sum and the probability metrics consume the caller's weight vector
+        # verbatim, so an all-zero vector really does arrive at the distance functions.
+        neutral = QuickAdapterRegressorV3._topsis_scores(
+            front(), "weighted_sum", distance_kwargs={}, weights=np.zeros(2)
+        )
+        decisive = QuickAdapterRegressorV3._topsis_scores(
+            front(), "weighted_sum", distance_kwargs={}, weights=UNIT_WEIGHTS
+        )
+        np.testing.assert_allclose(
+            decisive, np.array([1.0, 0.0, 0.5]), rtol=0.0, atol=NORMALIZED_ATOL
+        )
+        np.testing.assert_allclose(neutral, np.full(3, 0.5), rtol=0.0, atol=NORMALIZED_ATOL)
+
+    def test_a_lone_trial_is_unrankable_so_topsis_scores_it_one_half(self):
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._topsis_scores(
+                np.array([[0.3, 0.4]]), "euclidean", distance_kwargs={}
+            ),
+            np.array([0.5]),
+            rtol=0.0,
+            atol=NORMALIZED_ATOL,
+        )
+
+    def test_an_empty_front_scores_nothing_for_both_aggregate_methods(self):
+        empty = np.zeros((0, 2))
+        np.testing.assert_array_equal(
+            QuickAdapterRegressorV3._topsis_scores(empty, "euclidean", distance_kwargs={}),
+            np.array([]),
+        )
+        np.testing.assert_array_equal(
+            QuickAdapterRegressorV3._compromise_programming_scores(
+                empty, "euclidean", distance_kwargs={}
+            ),
+            np.array([]),
+        )
+
+    def test_a_lone_trial_has_no_distance_to_the_ideal_under_compromise_programming(self):
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._compromise_programming_scores(
+                np.array([[0.3, 0.4]]), "euclidean", distance_kwargs={}
+            ),
+            np.array([0.0]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_compromise_programming_measures_unsigned_distance_to_the_ideal(self):
+        # An unsigned distance, so the argmin is the trial nearest the all-ones ideal.
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._compromise_programming_scores(
+                FRONT, "euclidean", distance_kwargs={}
+            ),
+            np.array([np.sqrt(2.0), 1.0, np.sqrt(0.5)]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+        self.assertEqual(
+            int(
+                np.argmin(
+                    QuickAdapterRegressorV3._compromise_programming_scores(
+                        FRONT, "euclidean", distance_kwargs={}
+                    )
+                )
+            ),
+            2,
+        )
+
+    def test_compromise_programming_stays_signed_and_therefore_prefers_an_over_ideal_trial(self):
+        # The ideal point is all-ones, so a trial exceeding it scores negative and wins
+        # the argmin. A magnitude-based score would pick the other trial instead: the
+        # sign is load-bearing, not cosmetic.
+        stack = np.vstack([AT_IDEAL, ABOVE_IDEAL])
+        signed = QuickAdapterRegressorV3._compromise_programming_scores(
+            stack, "power_mean", distance_kwargs={}, p=1.0
+        )
+        self.assertLess(float(signed[1]), 0.0)
+        self.assertEqual(int(np.argmin(signed)), 1)
+        magnitude = np.abs(
+            QuickAdapterRegressorV3._compromise_programming_scores(
+                stack, "euclidean", distance_kwargs={}
+            )
+        )
+        self.assertEqual(int(np.argmin(magnitude)), 0)
+
+    def test_the_two_aggregate_methods_can_pick_different_trials_on_one_front(self):
+        # Both rank with an argmin, but euclidean measures the Euclidean distance to
+        # the ideal while power_mean measures the signed arithmetic mean, so the row
+        # nearest in one sense is not the row nearest in the other.
+        euclidean = QuickAdapterRegressorV3._compromise_programming_scores(
+            FRONT, "euclidean", distance_kwargs={}
+        )
+        power_mean = QuickAdapterRegressorV3._compromise_programming_scores(
+            FRONT, "power_mean", distance_kwargs={}, p=1.0
+        )
+        np.testing.assert_allclose(
+            euclidean, np.array([np.sqrt(2.0), 1.0, np.sqrt(0.5)]), rtol=DISTANCE_RTOL, atol=0.0
+        )
+        np.testing.assert_allclose(
+            power_mean, np.array([1.0, 0.5, 0.5]), rtol=DISTANCE_RTOL, atol=0.0
+        )
+        self.assertEqual(int(np.argmin(euclidean)), 2)
+        self.assertEqual(int(np.argmin(power_mean)), 1)
+
+    def test_topsis_uses_magnitudes_where_compromise_programming_uses_the_sign(self):
+        stack = np.vstack([AT_IDEAL, ABOVE_IDEAL])
+        ranking = QuickAdapterRegressorV3._topsis_scores(
+            stack, "power_mean", distance_kwargs={}, p=1.0
+        )
+        np.testing.assert_allclose(
+            ranking, np.array([0.0, 1.0 / 3.0]), rtol=0.0, atol=NORMALIZED_ATOL
+        )
+
+    def test_a_trials_distance_to_the_ideal_matches_the_aggregate_scoring_surviving_route(self):
+        # _calculate_trial_distance_to_ideal is what the reported best-trial distance
+        # comes from, so it must agree with the per-row aggregate distance.
+        for trial_index in range(FRONT.shape[0]):
+            with self.subTest(trial_index=trial_index):
+                np.testing.assert_allclose(
+                    QuickAdapterRegressorV3._calculate_trial_distance_to_ideal(
+                        FRONT, trial_index, IDEAL_2D, "euclidean", distance_kwargs={}
+                    ),
+                    np.sqrt(np.sum((IDEAL - FRONT[trial_index]) ** 2)),
+                    rtol=DISTANCE_RTOL,
+                    atol=0.0,
+                )
+
+    def test_the_reported_distance_honours_the_prepared_p(self):
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "minkowski", p=1.0, reference_matrix=unique_rows(4)
+        )
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._calculate_trial_distance_to_ideal(
+                FRONT, 2, IDEAL_2D, "minkowski", distance_kwargs=kwargs
+            ),
+            QuickAdapterRegressorV3._calculate_trial_distance_to_ideal(
+                FRONT, 2, IDEAL_2D, "cityblock", distance_kwargs={}
+            ),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    # ------------------------------------------------------------------ pairwise distance sums
+
+    def test_each_row_carries_the_sum_of_its_distances_to_every_other_row(self):
+        matrix = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 2.0]])
+        d01, d02 = 1.0, 2.0
+        d12 = float(np.hypot(1.0, 2.0))
+        expected = np.array([d01 + d02, d01 + d12, d02 + d12])
+        for row_index, value in enumerate(expected):
+            with self.subTest(row_index=row_index):
+                result = QuickAdapterRegressorV3._pairwise_distance_sums(
+                    matrix, "euclidean", distance_kwargs={}
+                )
+                self.assertAlmostEqual(float(result[row_index]), value, places=10)
+
+    def test_a_squared_metric_sums_squares_not_distances(self):
+        matrix = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 2.0]])
+        result = QuickAdapterRegressorV3._pairwise_distance_sums(
+            matrix, "sqeuclidean", distance_kwargs={}
+        )
+        for row_index, value in enumerate([5.0, 6.0, 9.0]):
+            with self.subTest(row_index=row_index):
+                self.assertAlmostEqual(float(result[row_index]), value, places=10)
+
+    def test_a_single_trial_and_an_empty_front_short_circuit_the_pairwise_sums(self):
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._pairwise_distance_sums(
+                np.array([[1.0, 2.0]]), "euclidean", distance_kwargs={}
+            ),
+            np.array([0.0]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+        np.testing.assert_array_equal(
+            QuickAdapterRegressorV3._pairwise_distance_sums(
+                np.zeros((0, 2)), "euclidean", distance_kwargs={}
+            ),
+            np.array([]),
+        )
+
+    def test_a_matrix_the_pairwise_route_cannot_score_is_refused(self):
+        for bad, pattern in (
+            (np.array([1.0, 2.0]), "must be 2-dimensional"),
+            (np.zeros((3, 0)), "at least one feature"),
+            (np.array([[1.0], [np.nan]]), "only finite values"),
+            (np.array([[1.0], [np.inf]]), "only finite values"),
+        ):
+            with self.subTest(bad=bad.tolist()), self.assertRaisesRegex(ValueError, pattern):
+                QuickAdapterRegressorV3._pairwise_distance_sums(
+                    bad, "euclidean", distance_kwargs={}
+                )
+
+    # ------------------------------------------------------------------ knn density selection
+
+    def test_every_trial_is_scored_by_its_own_aggregated_neighbour_distance(self):
+        # Points at 0, 1 and 4 on a line: the two nearest neighbours of each are at
+        # 1&4, 1&3 and 3&4 respectively.
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        expected = {
+            "power_mean": [2.5, 2.0, 3.5],
+            "quantile": [2.5, 2.0, 3.5],
+            "min": [1.0, 1.0, 3.0],
+            "max": [4.0, 3.0, 4.0],
+        }
+        for aggregation, values in expected.items():
+            with self.subTest(aggregation=aggregation):
+                result = QuickAdapterRegressorV3._knn_based_selection(
+                    line,
+                    aggregation,
+                    distance_kwargs={},
+                    distance_metric="euclidean",
+                    n_neighbors=2,
+                )
+                np.testing.assert_allclose(result, np.array(values), rtol=DISTANCE_RTOL, atol=0.0)
+
+    def test_a_single_neighbour_collapses_every_aggregation_to_the_nearest_distance(self):
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        for aggregation in QuickAdapterRegressorV3._DENSITY_AGGREGATIONS:
+            with self.subTest(aggregation=aggregation):
+                np.testing.assert_allclose(
+                    QuickAdapterRegressorV3._knn_based_selection(
+                        line,
+                        aggregation,
+                        distance_kwargs={},
+                        distance_metric="euclidean",
+                        n_neighbors=1,
+                    ),
+                    np.array([1.0, 1.0, 3.0]),
+                    rtol=DISTANCE_RTOL,
+                    atol=0.0,
+                )
+
+    def test_the_aggregation_param_moves_the_aggregate_when_it_is_supplied(self):
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        squared = QuickAdapterRegressorV3._knn_based_selection(
+            line,
+            "power_mean",
+            distance_kwargs={},
+            distance_metric="euclidean",
+            n_neighbors=2,
+            aggregation_param=2.0,
+        )
+        np.testing.assert_allclose(
+            squared, np.sqrt(np.array([8.5, 5.0, 12.5])), rtol=DISTANCE_RTOL, atol=0.0
+        )
+        first_quartile = QuickAdapterRegressorV3._knn_based_selection(
+            line,
+            "quantile",
+            distance_kwargs={},
+            distance_metric="euclidean",
+            n_neighbors=2,
+            aggregation_param=0.25,
+        )
+        np.testing.assert_allclose(
+            first_quartile, np.array([1.75, 1.5, 3.25]), rtol=DISTANCE_RTOL, atol=0.0
+        )
+
+    def test_an_absent_aggregation_param_falls_back_to_the_aggregation_default(self):
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        for aggregation, param in (("power_mean", 1.0), ("quantile", 0.5)):
+            with self.subTest(aggregation=aggregation):
+                implicit = QuickAdapterRegressorV3._knn_based_selection(
+                    line,
+                    aggregation,
+                    distance_kwargs={},
+                    distance_metric="euclidean",
+                    n_neighbors=2,
+                    aggregation_param=None,
+                )
+                explicit = QuickAdapterRegressorV3._knn_based_selection(
+                    line,
+                    aggregation,
+                    distance_kwargs={},
+                    distance_metric="euclidean",
+                    n_neighbors=2,
+                    aggregation_param=param,
+                )
+                np.testing.assert_allclose(implicit, explicit, rtol=DISTANCE_RTOL, atol=0.0)
+
+    def test_a_neighbourhood_larger_than_the_front_is_clamped_to_the_front(self):
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._knn_based_selection(
+                line, "min", distance_kwargs={}, distance_metric="euclidean", n_neighbors=99
+            ),
+            QuickAdapterRegressorV3._knn_based_selection(
+                line, "min", distance_kwargs={}, distance_metric="euclidean", n_neighbors=2
+            ),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_an_empty_neighbourhood_makes_every_trial_unselectable(self):
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        for n_neighbors in (0, -1):
+            with self.subTest(n_neighbors=n_neighbors):
+                result = QuickAdapterRegressorV3._knn_based_selection(
+                    line,
+                    "min",
+                    distance_kwargs={},
+                    distance_metric="euclidean",
+                    n_neighbors=n_neighbors,
+                )
+                self.assertTrue(np.all(np.isinf(result)))
+
+    def test_a_front_smaller_than_two_is_short_circuited(self):
+        np.testing.assert_array_equal(
+            QuickAdapterRegressorV3._knn_based_selection(
+                np.zeros((0, 2)),
+                "min",
+                distance_kwargs={},
+                distance_metric="euclidean",
+                n_neighbors=3,
+            ),
+            np.array([]),
+        )
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._knn_based_selection(
+                np.ones((1, 2)),
+                "min",
+                distance_kwargs={},
+                distance_metric="euclidean",
+                n_neighbors=3,
+            ),
+            np.array([0.0]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_the_knn_route_raises_on_an_unknown_aggregation(self):
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        aggregations = QuickAdapterRegressorV3._DENSITY_AGGREGATIONS
+        with self.assertRaises(ValueError) as caught:
+            QuickAdapterRegressorV3._knn_based_selection(
+                line, "median", distance_kwargs={}, distance_metric="euclidean", n_neighbors=1
+            )
+        self.assertEqual(
+            str(caught.exception), enum_error_message("aggregation", "median", aggregations)
+        )
+
+    def test_the_knn_route_validates_its_aggregation_param_and_raises_rather_than_warns(self):
+        # The knn route hard-codes the raise mode, so a bad aggregation param aborts
+        # the selection instead of being quietly downgraded to a different aggregate.
+        line = np.array([[0.0, 0.0], [1.0, 0.0], [4.0, 0.0]])
+        for aggregation, param, constraint in (
+            ("quantile", 1.5, r"must be in \[0, 1\]"),
+            ("quantile", -0.1, r"must be in \[0, 1\]"),
+            ("power_mean", np.inf, "must be finite"),
+        ):
+            with self.subTest(aggregation=aggregation, param=param):
+                with self.assertRaisesRegex(ValueError, constraint) as caught:
+                    QuickAdapterRegressorV3._knn_based_selection(
+                        line,
+                        aggregation,
+                        distance_kwargs={},
+                        distance_metric="euclidean",
+                        n_neighbors=2,
+                        aggregation_param=param,
+                    )
+                self.assertIn("label_density_aggregation_param", str(caught.exception))
+
+    # ------------------------------------------------------------------ trial selection
+
+    def test_the_lowest_trial_number_wins_independently_of_input_order(self):
+        for order in ((6, 1, 3), (3, 6, 1), (1, 3, 6)):
+            with self.subTest(order=order):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._select_lowest_number_trial(trials(*order)).number, 1
+                )
+
+    def test_the_closest_trial_to_the_ideal_wins(self):
+        self.assertEqual(
+            QuickAdapterRegressorV3._select_best_trial_by_distance(
+                trials(5, 2, 8), np.array([0.5, 0.1, 0.4])
+            ).number,
+            2,
+        )
+
+    def test_a_distance_tie_is_broken_by_the_lowest_trial_number(self):
+        self.assertEqual(
+            QuickAdapterRegressorV3._select_best_trial_by_distance(
+                trials(9, 4, 7), np.array([2.0, 1.0, 1.0])
+            ).number,
+            4,
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._select_best_trial_by_distance(
+                trials(5, 2, 8), np.array([1.0, 1.0, 2.0])
+            ).number,
+            2,
+        )
+
+    def test_a_non_finite_distance_removes_its_trial_from_the_contest(self):
+        for bad in (np.nan, np.inf, -np.inf):
+            with self.subTest(distance=bad):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._select_best_trial_by_distance(
+                        trials(6, 1, 3), np.array([bad, np.inf, 4.0])
+                    ).number,
+                    3,
+                )
+
+    def test_a_fully_non_finite_front_falls_back_to_the_lowest_trial_number(self):
+        for distances in (np.full(3, np.nan), np.full(3, np.inf), np.full(3, -np.inf)):
+            with self.subTest(distances=distances.tolist()):
+                with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+                    chosen = QuickAdapterRegressorV3._select_best_trial_by_distance(
+                        trials(6, 1, 3), distances
+                    )
+                self.assertEqual(chosen.number, 1)
+                self.assertIn("falling back to lowest trial number", captured.output[0])
+
+    def test_a_distance_vector_that_does_not_cover_the_trials_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "must match trials length"):
+            QuickAdapterRegressorV3._select_best_trial_by_distance(
+                trials(1, 2, 3), np.array([1.0, 2.0])
+            )
+
+    # ------------------------------------------------------------------ cluster count
+
+    def test_a_front_that_cannot_support_two_clusters_asks_for_one(self):
+        for matrix in (np.array([[1.0, 2.0]]), np.array([[1.0, 2.0], [1.0, 2.0], [1.0, 2.0]])):
+            with self.subTest(shape=matrix.shape):
+                self.assertEqual(QuickAdapterRegressorV3._get_n_clusters(matrix), 1)
+
+    def test_a_small_front_asks_for_one_cluster_per_distinct_row(self):
+        for n_rows in (2, 3):
+            with self.subTest(n_rows=n_rows):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._get_n_clusters(unique_rows(n_rows)), n_rows
+                )
+
+    def test_a_larger_front_follows_the_unique_row_growth_rule_within_its_bounds(self):
+        expected = {4: 2, 8: 3, 12: 4, 20: 4, 50: 6, 100: 8, 200: 10, 1000: 10}
+        for n_rows, n_clusters in expected.items():
+            with self.subTest(n_rows=n_rows):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._get_n_clusters(unique_rows(n_rows)), n_clusters
+                )
+
+    def test_the_cluster_count_counts_distinct_rows_not_rows(self):
+        # A front of 10 rows drawn from 4 distinct points has 4 distinct points, and the
+        # count grows with the distinct-row count rather than the row count. 10 distinct
+        # rows would ask for 3, so the two are separable here.
+        self.assertEqual(QuickAdapterRegressorV3._get_n_clusters(repeated_rows(10, 4)), 2)
+        self.assertEqual(QuickAdapterRegressorV3._get_n_clusters(repeated_rows(10, 10)), 3)
+        self.assertEqual(QuickAdapterRegressorV3._get_n_clusters(repeated_rows(50, 4)), 2)
+        self.assertEqual(QuickAdapterRegressorV3._get_n_clusters(repeated_rows(50, 50)), 6)
+
+    def test_a_duplicated_front_of_one_point_never_reaches_the_growth_rule(self):
+        for n_rows in (2, 5, 50):
+            with self.subTest(n_rows=n_rows):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._get_n_clusters(repeated_rows(n_rows, 1)), 1
+                )
+
+    def test_the_cluster_count_never_leaves_the_configured_window(self):
+        matrix = unique_rows(4)
+        self.assertEqual(QuickAdapterRegressorV3._get_n_clusters(matrix, min_n_clusters=3), 3)
+        self.assertEqual(
+            QuickAdapterRegressorV3._get_n_clusters(unique_rows(100), max_n_clusters=2), 2
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._get_n_clusters(unique_rows(1000), max_n_clusters=4), 4
+        )
+
+    # ------------------------------------------------------------------ extrema thresholds
+
+    def test_an_empty_extremum_set_yields_no_candidates(self):
+        # size 0 is the one case that may legitimately keep nothing, and keep_fraction
+        # does not force a candidate out of a set that does not exist.
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(0, 1.0), 0)
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(0, 0.0075), 0)
+
+    def test_a_non_empty_extremum_set_always_keeps_at_least_one(self):
+        for keep_fraction in (0.0, 0.0075, 0.5, 1.0):
+            with self.subTest(keep_fraction=keep_fraction):
+                self.assertGreaterEqual(
+                    QuickAdapterRegressorV3._calculate_n_kept_extrema(10, keep_fraction), 1
+                )
+
+    def test_the_kept_count_uses_banker_rounding_not_ceiling(self):
+        # round() is round-half-to-even: 133 * 0.5 == 66.5 keeps 66, not 67, and a
+        # keep_fraction of 0.0075 over 133 extrema still keeps only the floor of one.
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(133, 0.5), 66)
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(3, 0.5), 2)
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(133, 0.0075), 1)
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(10, 0.5), 5)
+        self.assertEqual(QuickAdapterRegressorV3._calculate_n_kept_extrema(10, 1.0), 10)
+
+    def test_partition_separates_the_two_sides_of_the_normalised_range(self):
+        # ±10 * eps, not 0: a label that rounds to exactly zero is neither extremum.
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(series, "partition")
+        self.assertEqual(minima.tolist(), [-0.5, -0.2, -0.95])
+        self.assertEqual(maxima.tolist(), [0.1, 0.9, 0.3, 0.05, 0.7])
+
+    def test_a_label_inside_the_partition_epsilon_is_neither_minimum_nor_maximum(self):
+        eps = 10 * np.finfo(float).eps
+        series = pd.Series([eps / 2, -eps / 2, 1.0, -1.0])
+        minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(series, "partition")
+        self.assertEqual(minima.tolist(), [-1.0])
+        self.assertEqual(maxima.tolist(), [1.0])
+
+    def test_ranking_keeps_the_most_extreme_peaks_first(self):
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        for selection_method in ("rank_extrema", "rank_peaks"):
+            with self.subTest(selection_method=selection_method):
+                minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(series, selection_method)
+                self.assertEqual(minima.tolist(), [-0.95, -0.5, -0.2])
+                self.assertEqual(maxima.iloc[0], 0.9)
+
+    def test_the_keep_fraction_shrinks_the_candidate_pool_to_the_extremes(self):
+        wave = pd.Series([0.0, 1.0, 0.0, -1.0, 0.0, 2.0, 0.0, -2.0, 0.0])
+        for selection_method in ("rank_extrema", "rank_peaks"):
+            with self.subTest(selection_method=selection_method):
+                full = QuickAdapterRegressorV3.get_pred_min_max(wave, selection_method, 1.0)
+                half = QuickAdapterRegressorV3.get_pred_min_max(wave, selection_method, 0.5)
+                self.assertEqual(full[0].tolist(), [-2.0, -1.0])
+                self.assertEqual(half[0].tolist(), [-2.0])
+                self.assertEqual(full[1].tolist(), [2.0, 1.0])
+                self.assertEqual(half[1].tolist(), [2.0])
+
+    def test_a_monotone_series_has_no_interior_extrema_at_all(self):
+        rising = pd.Series([1.0, 2.0, 3.0])
+        for selection_method in ("rank_extrema", "rank_peaks"):
+            with self.subTest(selection_method=selection_method):
+                minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(rising, selection_method)
+                self.assertTrue(minima.empty)
+                self.assertTrue(maxima.empty)
+
+    def test_a_non_finite_label_cannot_become_a_candidate(self):
+        series = pd.Series([0.1, np.inf, -np.inf, np.nan, 0.9, -0.5])
+        minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(series, "partition")
+        self.assertEqual(minima.tolist(), [-0.5])
+        self.assertEqual(maxima.tolist(), [0.1, 0.9])
+
+    def test_a_fully_non_finite_series_yields_no_candidates_and_the_sentinel_bounds(self):
+        minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(
+            pd.Series([np.nan, np.inf, -np.inf]), "partition"
+        )
+        self.assertTrue(minima.empty)
+        self.assertTrue(maxima.empty)
+        self.assertEqual(
+            QuickAdapterRegressorV3.median_min_max(pd.Series(dtype=float), "partition"), (-2.0, 2.0)
+        )
+
+    def test_a_non_numeric_label_is_coerced_to_nan_and_dropped(self):
+        minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(
+            pd.Series(["0.5", "bad", "-0.5"]), "partition"
+        )
+        self.assertEqual(minima.tolist(), [-0.5])
+        self.assertEqual(maxima.tolist(), [0.5])
+
+    def test_partition_preserves_the_callers_index(self):
+        series = pd.Series([0.1, 0.2, 0.3], index=[10, 11, 12])
+        self.assertEqual(
+            QuickAdapterRegressorV3.get_pred_min_max(series, "partition")[1].index.tolist(),
+            [10, 11, 12],
+        )
+
+    def test_an_unknown_selection_method_is_named_in_the_canonical_enum_error(self):
+        with self.assertRaises(ValueError) as caught:
+            QuickAdapterRegressorV3.get_pred_min_max(pd.Series([1.0, 2.0]), "extremes")
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("selection_method", "extremes", EXTREMA_SELECTION_METHODS),
+        )
+
+    # ------------------------------------------------------------------ sentinel bounds
+
+    def test_the_fallback_bounds_sit_outside_the_normalised_label_range(self):
+        low, high = QuickAdapterRegressorV3.RANGE_DEFAULT
+        self.assertEqual(QuickAdapterRegressorV3.safe_min_pred(pd.Series(dtype=float)), -2.0)
+        self.assertEqual(QuickAdapterRegressorV3.safe_max_pred(pd.Series(dtype=float)), 2.0)
+        self.assertLess(-2.0, low)
+        self.assertGreater(2.0, high)
+
+    def test_an_empty_or_non_finite_prediction_takes_the_sentinel(self):
+        for series in (
+            pd.Series(dtype=float),
+            pd.Series([np.nan, np.nan]),
+            pd.Series([np.nan, np.inf]),
+        ):
+            with self.subTest(series=series.tolist()):
+                self.assertEqual(QuickAdapterRegressorV3.safe_min_pred(series), -2.0)
+                self.assertEqual(QuickAdapterRegressorV3.safe_max_pred(series), 2.0)
+
+    def test_a_finite_prediction_never_takes_the_sentinel(self):
+        series = pd.Series([-0.5, 0.1, 0.2])
+        self.assertAlmostEqual(QuickAdapterRegressorV3.safe_min_pred(series), -0.5, places=12)
+        self.assertAlmostEqual(QuickAdapterRegressorV3.safe_max_pred(series), 0.2, places=12)
+
+    def test_a_reducer_that_raises_or_returns_nothing_usable_takes_the_sentinel(self):
+        def raising(_series: pd.Series) -> float:
+            raise RuntimeError("no rows")
+
+        series = pd.Series([1.0, 2.0])
+        for reducer in (
+            raising,
+            lambda s: s,
+            lambda s: np.nan,
+            lambda s: np.inf,
+            lambda s: "x",
+            lambda s: np.array([1.0]),
+        ):
+            with self.subTest(reducer=reducer):
+                self.assertEqual(QuickAdapterRegressorV3._safe_pred(series, reducer, 9.0), 9.0)
+        self.assertEqual(QuickAdapterRegressorV3._safe_pred(series, lambda s: 0.5, 9.0), 0.5)
+
+    def test_only_the_non_finite_side_is_replaced_by_the_sentinel(self):
+        series = pd.Series([-1.0, 1.0])
+        self.assertEqual(
+            QuickAdapterRegressorV3._resolve_min_max(np.nan, np.nan, series), (-1.0, 1.0)
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._resolve_min_max(0.25, np.nan, series), (0.25, 1.0)
+        )
+        self.assertEqual(
+            QuickAdapterRegressorV3._resolve_min_max(np.nan, 0.75, series), (-1.0, 0.75)
+        )
+        self.assertEqual(QuickAdapterRegressorV3._resolve_min_max(0.25, 0.75, series), (0.25, 0.75))
+
+    def test_a_one_sided_extremum_set_leaves_the_other_side_on_the_sentinel(self):
+        positive = pd.Series([0.1, 0.2, 0.3, 0.4, 0.5])
+        negative = pd.Series([-0.1, -0.2, -0.3, -0.4, -0.5])
+        # partition keeps only one side of zero, so the absent side has no candidate and
+        # falls back to the full-series extremum rather than to the sentinel.
+        minimum, maximum = QuickAdapterRegressorV3.median_min_max(positive, "partition")
+        self.assertAlmostEqual(float(minimum), 0.1, places=9)
+        self.assertAlmostEqual(float(maximum), 0.3, places=9)
+        minimum, maximum = QuickAdapterRegressorV3.median_min_max(negative, "partition")
+        self.assertAlmostEqual(float(minimum), -0.3, places=9)
+        self.assertAlmostEqual(float(maximum), -0.1, places=9)
+
+    # ------------------------------------------------------------------ threshold surfaces
+
+    def test_the_median_surface_medianises_the_candidate_sets(self):
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        minimum, maximum = QuickAdapterRegressorV3.median_min_max(series, "rank_extrema")
+        self.assertAlmostEqual(float(minimum), -0.5, places=9)
+        self.assertAlmostEqual(float(maximum), 0.8, places=9)
+
+    def test_the_soft_extremum_surface_hardens_itself_as_alpha_grows(self):
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        soft = QuickAdapterRegressorV3.soft_extremum_min_max(series, 12.0, "rank_extrema")
+        hard = QuickAdapterRegressorV3.get_pred_min_max(series, "rank_extrema")
+        self.assertAlmostEqual(float(soft[0]), -0.9478847957509023, places=9)
+        self.assertAlmostEqual(float(soft[1]), 0.8833654607012157, places=9)
+        self.assertGreater(float(soft[0]), float(hard[0].min()))
+        self.assertLess(float(soft[1]), float(hard[1].max()))
+
+    def test_a_zero_alpha_reduces_the_soft_extremum_to_the_plain_mean(self):
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        minimum, maximum = QuickAdapterRegressorV3.soft_extremum_min_max(
+            series, 0.0, "rank_extrema"
+        )
+        minima, maxima = QuickAdapterRegressorV3.get_pred_min_max(series, "rank_extrema")
+        self.assertAlmostEqual(float(minimum), float(minima.mean()), places=9)
+        self.assertAlmostEqual(float(maximum), float(maxima.mean()), places=9)
+
+    def test_a_negative_alpha_is_refused_before_any_extremum_is_computed(self):
+        with self.assertRaisesRegex(ValueError, "must be >= 0"):
+            QuickAdapterRegressorV3.soft_extremum_min_max(
+                pd.Series([1.0, 2.0]), -0.1, "rank_extrema"
+            )
+
+    def test_the_skimage_surface_delegates_to_the_requested_threshold(self):
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        for method in SKIMAGE_THRESHOLD_METHODS:
+            with self.subTest(method=method):
+                minimum, maximum = QuickAdapterRegressorV3.skimage_min_max(
+                    series, method, "rank_extrema"
+                )
+                self.assertTrue(-1.0 <= float(minimum) <= float(maximum) <= 1.0)
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3.skimage_min_max(series, "otsu", "rank_extrema"),
+            (-0.94853515625, 0.8),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_an_unknown_skimage_threshold_is_named_in_the_canonical_enum_error(self):
+        with self.assertRaises(ValueError) as caught:
+            QuickAdapterRegressorV3.skimage_min_max(
+                pd.Series([0.1, 0.2, 0.3]), "entropy", "rank_extrema"
+            )
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("skimage threshold method", "entropy", SKIMAGE_THRESHOLD_METHODS),
+        )
+
+    def test_an_empty_extremum_set_returns_nan_so_the_caller_falls_back(self):
+        # The threshold helper signals "cannot be computed" with NaN, and _resolve_min_max
+        # turns that into the ±2.0 sentinel, so the reported bounds stay finite.
+        self.assertTrue(
+            np.isnan(
+                QuickAdapterRegressorV3.apply_skimage_threshold(
+                    pd.Series(dtype=float), lambda v: 0.0
+                )
+            )
+        )
+        for surface in (
+            QuickAdapterRegressorV3.median_min_max,
+            QuickAdapterRegressorV3.skimage_min_max,
+        ):
+            with self.subTest(surface=surface.__name__):
+                minimum, maximum = (
+                    surface(pd.Series(dtype=float), "rank_extrema")
+                    if surface is QuickAdapterRegressorV3.median_min_max
+                    else surface(pd.Series(dtype=float), "otsu", "rank_extrema")
+                )
+                self.assertEqual((minimum, maximum), (-2.0, 2.0))
+
+    def test_a_thresholdable_extremum_set_never_returns_nan(self):
+        series = pd.Series([0.1, -0.5, 0.9, -0.2, 0.3, 0.05, -0.95, 0.7])
+        for method in SKIMAGE_THRESHOLD_METHODS:
+            with self.subTest(method=method):
+                for surface in (
+                    QuickAdapterRegressorV3.median_min_max,
+                    QuickAdapterRegressorV3.skimage_min_max,
+                ):
+                    result = (
+                        surface(series, "rank_extrema")
+                        if surface is QuickAdapterRegressorV3.median_min_max
+                        else surface(series, method, "rank_extrema")
+                    )
+                    self.assertFalse(np.isnan(result[0]))
+                    self.assertFalse(np.isnan(result[1]))
+
+    def test_a_single_element_or_two_distinct_candidate_set_falls_back_to_its_median(self):
+        calls, spy = threshold_spy()
+        self.assertAlmostEqual(
+            QuickAdapterRegressorV3.apply_skimage_threshold(pd.Series([4.0]), spy), 4.0, places=12
+        )
+        self.assertAlmostEqual(
+            QuickAdapterRegressorV3.apply_skimage_threshold(pd.Series([1.0, 4.0]), spy),
+            2.5,
+            places=12,
+        )
+        self.assertEqual(calls, [])
+
+    def test_a_candidate_set_with_fewer_than_three_distinct_values_cannot_be_thresholded(self):
+        # Otsu and friends need a histogram with interior bins, so a two-valued
+        # candidate set is medianised instead of being forced through the function.
+        calls, spy = threshold_spy()
+        result = QuickAdapterRegressorV3.apply_skimage_threshold(pd.Series([1.0, 4.0]), spy)
+        self.assertEqual(calls, [])
+        self.assertAlmostEqual(result, 2.5, places=12)
+
+    def test_a_numerically_flat_candidate_set_is_medianised(self):
+        calls, spy = threshold_spy()
+        result = QuickAdapterRegressorV3.apply_skimage_threshold(pd.Series([1.0, 1.0, 1.0]), spy)
+        self.assertEqual(calls, [])
+        self.assertAlmostEqual(result, 1.0, places=12)
+
+    def test_a_healthy_candidate_set_is_handed_to_the_threshold_verbatim(self):
+        calls, spy = threshold_spy()
+        result = QuickAdapterRegressorV3.apply_skimage_threshold(pd.Series([1.0, 2.0, 5.0]), spy)
+        self.assertEqual(result, 99.0)
+        self.assertEqual(len(calls), 1)
+        np.testing.assert_allclose(calls[0], np.array([1.0, 2.0, 5.0]), rtol=0.0, atol=0.0)
+
+    def test_a_threshold_that_raises_is_reported_and_medianised(self):
+        def exploding(_values: np.ndarray) -> float:
+            raise RuntimeError("no interior bins")
+
+        series = pd.Series([1.0, 2.0, 5.0])
+        with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+            result = QuickAdapterRegressorV3.apply_skimage_threshold(series, exploding)
+        self.assertAlmostEqual(float(result), 2.0, places=12)
+        self.assertIn("falling back to median", captured.output[0])
+
+    def test_an_all_nan_candidate_set_has_no_median_either(self):
+        calls, spy = threshold_spy()
+        self.assertTrue(
+            np.isnan(
+                QuickAdapterRegressorV3.apply_skimage_threshold(
+                    pd.Series([np.nan, np.nan, np.nan]), spy
+                )
+            )
+        )
+        self.assertEqual(calls, [])
+
+    def test_an_integer_candidate_series_reaches_the_threshold_unconverted(self):
+        # to_numpy() preserves the caller's dtype: an int64 label reaches the
+        # threshold function as int64, which is what skimage's histogram wants.
+        calls, spy = threshold_spy()
+        self.assertEqual(
+            QuickAdapterRegressorV3.apply_skimage_threshold(pd.Series([1, 2, 9]), spy), 99.0
+        )
+        self.assertEqual(calls[0].dtype, np.dtype(np.int64))
+        np.testing.assert_array_equal(calls[0], np.array([1, 2, 9]))
+
+    # ------------------------------------------------------------------ scalar validators
+
+    def test_a_non_finite_scalar_is_refused_in_warn_and_raise_modes_and_silently_dropped_in_none(
+        self,
+    ):
+        for value in (np.inf, -np.inf, np.nan):
+            with self.subTest(value=value), self.assertRaisesRegex(ValueError, "must be finite"):
+                QuickAdapterRegressorV3._validate_scalar(value, ctx="label_p_order", mode=RAISE)
+                with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+                    warned = QuickAdapterRegressorV3._validate_scalar(
+                        value, ctx="label_p_order", mode=WARN
+                    )
+                self.assertIsNone(warned)
+                self.assertIn("must be finite", captured.output[0])
+                with self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                    self.assertIsNone(
+                        QuickAdapterRegressorV3._validate_scalar(
+                            value, ctx="label_p_order", mode=NONE
+                        )
+                    )
+
+    def test_a_predicate_violation_is_reported_with_the_supplied_constraint(self):
+        with self.assertRaisesRegex(ValueError, "must be > 0"):
+            QuickAdapterRegressorV3._validate_scalar(
+                -1.0,
+                ctx="label_p_order",
+                mode=RAISE,
+                predicate=lambda v: v > 0,
+                constraint="must be > 0",
+            )
+        with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+            self.assertIsNone(
+                QuickAdapterRegressorV3._validate_scalar(
+                    -1.0,
+                    ctx="label_p_order",
+                    mode=WARN,
+                    predicate=lambda v: v > 0,
+                    constraint="must be > 0",
+                )
+            )
+        self.assertIn("must be > 0", captured.output[0])
+
+    def test_a_valid_scalar_is_returned_as_a_float_in_every_mode(self):
+        for mode in (RAISE, WARN, NONE):
+            with self.subTest(mode=mode), self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                result = QuickAdapterRegressorV3._validate_scalar(3, ctx="label_p_order", mode=mode)
+                self.assertIsInstance(result, float)
+                self.assertAlmostEqual(result, 3.0, places=12)
+
+    def test_a_missing_scalar_is_never_validated(self):
+        for mode in (RAISE, WARN, NONE):
+            with self.subTest(mode=mode), self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                self.assertIsNone(
+                    QuickAdapterRegressorV3._validate_scalar(None, ctx="x", mode=mode)
+                )
+
+    def test_the_minkowski_p_must_be_strictly_positive(self):
+        with self.assertRaisesRegex(ValueError, "must be > 0"):
+            QuickAdapterRegressorV3._validate_minkowski_p(0.0, ctx="label_p_order")
+        for valid in (1e-9, 1.0, 2.0, np.inf - 0.0):
+            with self.subTest(valid=valid):
+                if not np.isfinite(valid):
+                    continue
+                self.assertAlmostEqual(
+                    QuickAdapterRegressorV3._validate_minkowski_p(valid, ctx="label_p_order"),
+                    valid,
+                    places=12,
+                )
+        self.assertIsNone(QuickAdapterRegressorV3._validate_minkowski_p(None, ctx="label_p_order"))
+
+    def test_the_quantile_is_closed_at_both_ends(self):
+        for valid in (0.0, 0.5, 1.0):
+            with self.subTest(valid=valid):
+                self.assertAlmostEqual(
+                    QuickAdapterRegressorV3._validate_quantile_q(valid, ctx="label_quantile"),
+                    valid,
+                    places=12,
+                )
+        for invalid in (-1e-9, 1.0 + 1e-9):
+            with (
+                self.subTest(invalid=invalid),
+                self.assertRaisesRegex(ValueError, r"must be in \[0, 1\]"),
+            ):
+                QuickAdapterRegressorV3._validate_quantile_q(invalid, ctx="label_quantile")
+
+    def test_the_power_mean_p_is_only_required_to_be_finite(self):
+        # p=0 is the geometric mean and p<0 a harmonic-style mean, so unlike the
+        # Minkowski order there is no sign constraint here.
+        for valid in (-2.0, -1.0, 0.0, 1.0, 99.0):
+            with self.subTest(valid=valid):
+                self.assertAlmostEqual(
+                    QuickAdapterRegressorV3._validate_power_mean_p(valid, ctx="label_p_order"),
+                    valid,
+                    places=12,
+                )
+        for invalid in (np.inf, np.nan):
+            with (
+                self.subTest(invalid=invalid),
+                self.assertRaisesRegex(ValueError, "must be finite"),
+            ):
+                QuickAdapterRegressorV3._validate_power_mean_p(invalid, ctx="label_p_order")
+
+    def test_every_scalar_validator_keeps_its_own_declared_default_mode(self):
+        # The defaults are the user-facing contract: raise for a scalar the user typed,
+        # but _validate_metric_weights_support and _validate_label_selection_metric
+        # default to warn because they guard a whole config block.
+        for validator in (
+            QuickAdapterRegressorV3._validate_minkowski_p,
+            QuickAdapterRegressorV3._validate_quantile_q,
+            QuickAdapterRegressorV3._validate_power_mean_p,
+        ):
+            with self.subTest(validator=validator.__name__), self.assertRaises(ValueError):
+                validator(np.inf, ctx="ctx")
+        with self.assertLogs(LOGGER_NAME, "WARNING"):
+            self.assertIsNone(
+                QuickAdapterRegressorV3._validate_metric_weights_support(
+                    "mahalanobis", ctx="label_distance_metric"
+                )
+            )
+        with self.assertLogs(LOGGER_NAME, "WARNING"):
+            self.assertEqual(
+                QuickAdapterRegressorV3._validate_label_selection_metric(
+                    "hellinger",
+                    ctx="label_distance_metric",
+                    default="euclidean",
+                    aggregate_allowed=False,
+                ),
+                "euclidean",
+            )
+
+    def test_the_p_order_default_only_applies_when_the_user_supplied_nothing(self):
+        self.assertAlmostEqual(
+            QuickAdapterRegressorV3._resolve_p_order("minkowski", None, ctx="label_p_order"),
+            2.0,
+            places=12,
+        )
+        self.assertAlmostEqual(
+            QuickAdapterRegressorV3._resolve_p_order("power_mean", None, ctx="label_p_order"),
+            1.0,
+            places=12,
+        )
+        self.assertAlmostEqual(
+            QuickAdapterRegressorV3._resolve_p_order("minkowski", 3.0, ctx="label_p_order"),
+            3.0,
+            places=12,
+        )
+
+    def test_a_metric_without_a_p_order_default_yields_none(self):
+        for metric in ("euclidean", "cityblock", "hellinger", "weighted_sum"):
+            with self.subTest(metric=metric):
+                self.assertIsNone(QuickAdapterRegressorV3._resolve_p_order(metric, None, ctx="p"))
+
+    def test_only_the_minkowski_p_order_is_validated_on_the_resolved_value(self):
+        # Every other metric's p is either unused or a power mean, and this source
+        # deliberately does not constrain it, so the value passes straight through.
+        self.assertAlmostEqual(
+            QuickAdapterRegressorV3._resolve_p_order("power_mean", -5.0, ctx="label_p_order"),
+            -5.0,
+            places=12,
+        )
+        with self.assertRaisesRegex(ValueError, "must be > 0"):
+            QuickAdapterRegressorV3._resolve_p_order("minkowski", -1.0, ctx="label_p_order")
+        for mode in (WARN, NONE):
+            with self.subTest(mode=mode):
+                self.assertIsNone(
+                    QuickAdapterRegressorV3._resolve_p_order(
+                        "minkowski", -1.0, ctx="label_p_order", mode=mode
+                    )
+                )
+
+    def test_the_metric_weights_support_gate_names_the_metrics_that_reject_weights(self):
+        for metric in QuickAdapterRegressorV3._UNSUPPORTED_WEIGHTS_METRICS:
+            with self.subTest(metric=metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._validate_metric_weights_support(
+                        metric, ctx="label_distance_metric", mode=NONE
+                    ),
+                    None,
+                )
+                with self.assertRaisesRegex(ValueError, "does not support custom weights"):
+                    QuickAdapterRegressorV3._validate_metric_weights_support(
+                        metric, ctx="label_distance_metric", mode=RAISE
+                    )
+                with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+                    self.assertIsNone(
+                        QuickAdapterRegressorV3._validate_metric_weights_support(
+                            metric, ctx="label_distance_metric", mode=WARN
+                        )
+                    )
+                self.assertIn("using uniform weights", captured.output[0])
+
+    def test_a_metric_that_accepts_weights_is_returned_unchanged(self):
+        for metric in ("euclidean", "minkowski", "power_mean", "weighted_sum", "hellinger"):
+            with self.subTest(metric=metric), self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._validate_metric_weights_support(
+                        metric, ctx="label_distance_metric", mode=RAISE
+                    ),
+                    metric,
+                )
+
+    def test_a_missing_weight_vector_becomes_a_uniform_one(self):
+        for n_objectives in (1, 4, 7):
+            with self.subTest(n_objectives=n_objectives):
+                result = QuickAdapterRegressorV3._validate_label_weights(
+                    None, n_objectives, ctx="label_weights"
+                )
+                np.testing.assert_allclose(
+                    result, np.full(n_objectives, 1.0 / n_objectives), rtol=DISTANCE_RTOL, atol=0.0
+                )
+
+    def test_a_weight_vector_is_normalised_to_sum_to_one(self):
+        for weights in ([1.0, 3.0], (1.0, 3.0), np.array([1.0, 3.0]), [0.0, 2.0], [5.0, 5.0]):
+            with self.subTest(weights=np.asarray(weights).tolist()):
+                result = QuickAdapterRegressorV3._validate_label_weights(
+                    weights, 2, ctx="label_weights"
+                )
+                self.assertAlmostEqual(float(result.sum()), 1.0, places=12)
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._validate_label_weights([1.0, 3.0], 2, ctx="label_weights"),
+            np.array([0.25, 0.75]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_normalising_a_weight_vector_never_mutates_the_callers_array(self):
+        weights = np.array([1.0, 3.0])
+        QuickAdapterRegressorV3._validate_label_weights(weights, 2, ctx="label_weights")
+        np.testing.assert_array_equal(weights, np.array([1.0, 3.0]))
+
+    def test_a_weight_vector_is_scaled_by_its_own_maximum_before_summing(self):
+        # Dividing by the maximum first is what keeps a vector of huge weights from
+        # overflowing the sum into a non-finite normalisation.
+        result = QuickAdapterRegressorV3._validate_label_weights(
+            [1e308, 1e308], 2, ctx="label_weights"
+        )
+        np.testing.assert_allclose(result, np.array([0.5, 0.5]), rtol=DISTANCE_RTOL, atol=0.0)
+        tiny = QuickAdapterRegressorV3._validate_label_weights(
+            [1e-320, 2e-320], 2, ctx="label_weights"
+        )
+        np.testing.assert_allclose(
+            tiny, np.array([1.0 / 3.0, 2.0 / 3.0]), rtol=DISTANCE_RTOL, atol=0.0
+        )
+
+    def test_an_unusable_weight_vector_falls_back_to_a_uniform_one_per_mode(self):
+        cases = [
+            ("label_weights", "str", lambda: "nope", ValueError, "must be a list, tuple, or array"),
+            ("label_weights", "ndim", lambda: [[1.0, 2.0]], ValueError, "one-dimensional"),
+            ("label_weights", "size", lambda: [1.0, 2.0], ValueError, "must contain 3 weights"),
+            ("label_weights", "non-finite", lambda: [1.0, np.inf], ValueError, "non-finite"),
+            ("label_weights", "negative", lambda: [1.0, -1.0], ValueError, "negative"),
+            ("label_weights", "zero sum", lambda: [0.0, 0.0], ValueError, "sum is zero"),
+            ("label_weights", "non-numeric", lambda: ["a", "b"], ValueError, "numeric weights"),
+        ]
+        for ctx, label, build, error, pattern in cases:
+            with self.subTest(case=label):
+                n_objectives = 3 if label == "size" else 2
+                with self.assertRaisesRegex(error, pattern):
+                    QuickAdapterRegressorV3._validate_label_weights(build(), n_objectives, ctx=ctx)
+                with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+                    warned = QuickAdapterRegressorV3._validate_label_weights(
+                        build(), n_objectives, ctx=ctx, mode=WARN
+                    )
+                with self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                    silent = QuickAdapterRegressorV3._validate_label_weights(
+                        build(), n_objectives, ctx=ctx, mode=NONE
+                    )
+                np.testing.assert_allclose(
+                    warned, np.full(n_objectives, 1.0 / n_objectives), rtol=DISTANCE_RTOL, atol=0.0
+                )
+                np.testing.assert_allclose(
+                    silent, np.full(n_objectives, 1.0 / n_objectives), rtol=DISTANCE_RTOL, atol=0.0
+                )
+                self.assertIn("using uniform weights", captured.output[0])
+
+    def test_a_fully_zero_weight_vector_is_refused_rather_than_normalised_to_nan(self):
+        with self.assertRaisesRegex(ValueError, "sum is zero"):
+            QuickAdapterRegressorV3._validate_label_weights([0.0, 0.0], 2, ctx="label_weights")
+
+    def test_a_weight_vector_with_a_zero_and_a_positive_entry_normalises(self):
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._validate_label_weights([0.0, 2.0], 2, ctx="label_weights"),
+            np.array([0.0, 1.0]),
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_an_enum_value_outside_the_valid_set_raises_the_canonical_message(self):
+        valid = {"euclidean", "cityblock"}
+        options = ("euclidean", "cityblock")
+        for value in ("cosine", 5, None, b"euclidean"):
+            with self.subTest(value=value):
+                with self.assertRaises(ValueError) as caught:
+                    QuickAdapterRegressorV3._validate_enum_value(
+                        value, valid, options, ctx="label_distance_metric"
+                    )
+                self.assertEqual(
+                    str(caught.exception),
+                    enum_error_message("label_distance_metric", value, options),
+                )
+
+    def test_an_enum_value_outside_the_valid_set_resolves_to_the_declared_default(self):
+        valid = {"euclidean", "cityblock"}
+        for mode in (WARN, NONE):
+            with self.subTest(mode=mode):
+                if mode == WARN:
+                    with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+                        result = QuickAdapterRegressorV3._validate_enum_value(
+                            "cosine",
+                            valid,
+                            ("euclidean", "cityblock"),
+                            ctx="label_distance_metric",
+                            mode=mode,
+                            default="euclidean",
+                        )
+                    self.assertIn("using 'euclidean'", captured.output[0])
+                else:
+                    with self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                        result = QuickAdapterRegressorV3._validate_enum_value(
+                            "cosine",
+                            valid,
+                            ("euclidean", "cityblock"),
+                            ctx="label_distance_metric",
+                            mode=mode,
+                            default="euclidean",
+                        )
+                self.assertEqual(result, "euclidean")
+
+    def test_a_valid_enum_value_passes_through_untouched_and_unlogged(self):
+        with self.assertNoLogs(LOGGER_NAME, "WARNING"):
+            self.assertEqual(
+                QuickAdapterRegressorV3._validate_enum_value(
+                    "cityblock", {"euclidean", "cityblock"}, ("euclidean", "cityblock"), ctx="ctx"
+                ),
+                "cityblock",
+            )
+
+    def test_an_aggregate_route_accepts_every_non_probability_metric(self):
+        # The aggregate methods reduce over objectives themselves, so the whole non-probability
+        # set is admissible, including the named power means and the weighted sum.
+        for metric in QuickAdapterRegressorV3._DISTANCE_METRICS:
+            if metric in QuickAdapterRegressorV3._PROBABILITY_DISTANCE_METRICS_SET:
+                continue
+            with self.subTest(metric=metric), self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._validate_label_selection_metric(
+                        metric,
+                        ctx="label_distance_metric",
+                        default="euclidean",
+                        aggregate_allowed=True,
+                    ),
+                    metric,
+                )
+
+    def test_an_aggregate_route_rejects_the_probability_metrics_too(self):
+        # The three probability metrics are excluded from the aggregate set as well: they
+        # compare distributions across objectives, and a minimised objective column is a
+        # per-objective score, not a distribution over objectives.
+        for metric in QuickAdapterRegressorV3._PROBABILITY_DISTANCE_METRICS_SET:
+            with self.subTest(metric=metric), self.assertRaises(ValueError):
+                QuickAdapterRegressorV3._validate_label_selection_metric(
+                    metric,
+                    ctx="label_distance_metric",
+                    default="euclidean",
+                    aggregate_allowed=True,
+                    mode=RAISE,
+                )
+
+    def test_a_cluster_or_density_route_rejects_the_aggregate_metrics(self):
+        # Those routes hand the metric to SciPy/sklearn, which computes no reduction and
+        # therefore cannot evaluate a power mean or a weighted sum at all.
+        for metric in ("harmonic_mean", "quadratic_mean", "power_mean", "weighted_sum"):
+            with self.subTest(metric=metric):
+                with self.assertRaises(ValueError) as caught:
+                    QuickAdapterRegressorV3._validate_label_selection_metric(
+                        metric,
+                        ctx="label_distance_metric",
+                        default="euclidean",
+                        aggregate_allowed=False,
+                        mode=RAISE,
+                    )
+                self.assertIn("supported values are euclidean, minkowski", str(caught.exception))
+                self.assertNotIn(
+                    "power_mean", str(caught.exception).split("supported values are ")[1]
+                )
+
+    def test_a_cluster_or_density_route_rejects_the_probability_metrics_too(self):
+        for metric in QuickAdapterRegressorV3._PROBABILITY_DISTANCE_METRICS_SET:
+            with self.subTest(metric=metric), self.assertRaises(ValueError):
+                QuickAdapterRegressorV3._validate_label_selection_metric(
+                    metric,
+                    ctx="label_distance_metric",
+                    default="euclidean",
+                    aggregate_allowed=False,
+                    mode=RAISE,
+                )
+
+    def test_a_cluster_or_density_route_warns_towards_its_default_by_default(self):
+        with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+            result = QuickAdapterRegressorV3._validate_label_selection_metric(
+                "hellinger",
+                ctx="label_distance_metric",
+                default="cityblock",
+                aggregate_allowed=False,
+            )
+        self.assertEqual(result, "cityblock")
+        self.assertIn("using 'cityblock'", captured.output[0])
+
+    # ------------------------------------------------------------------ cdist keyword preparation
+
+    def test_no_weights_and_no_p_means_no_keywords_at_all(self):
+        for metric in ("euclidean", "cityblock", "hellinger", "power_mean"):
+            with self.subTest(metric=metric):
+                self.assertEqual(
+                    QuickAdapterRegressorV3._prepare_distance_kwargs(
+                        metric, reference_matrix=unique_rows(4)
+                    ),
+                    {},
+                )
+
+    def test_a_supported_metric_receives_the_weight_vector_verbatim(self):
+        weights = np.array([0.25, 0.75])
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "cityblock", weights=weights, reference_matrix=unique_rows(4)
+        )
+        self.assertEqual(set(kwargs), {"w"})
+        np.testing.assert_array_equal(kwargs["w"], weights)
+
+    def test_an_unsupported_metric_drops_the_weight_vector_silently_by_default(self):
+        # The default mode is "none": the block is dropped, no warning, and the
+        # standardized keyword is still built so the distance is computable.
+        reference = unique_rows(4)
+        for metric in QuickAdapterRegressorV3._UNSUPPORTED_WEIGHTS_METRICS:
+            if metric not in QuickAdapterRegressorV3._STANDARDIZED_DISTANCE_METRICS_SET:
+                with self.subTest(metric=metric), self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                    kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+                        metric, weights=np.array([0.5, 0.5]), reference_matrix=reference
+                    )
+                    self.assertNotIn("w", kwargs)
+                continue
+            with self.subTest(metric=metric), self.assertNoLogs(LOGGER_NAME, "WARNING"):
+                kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+                    metric, weights=np.array([0.5, 0.5]), reference_matrix=reference
+                )
+                self.assertNotIn("w", kwargs)
+                self.assertIn("V" if metric == "seuclidean" else "VI", kwargs)
+
+    def test_the_weights_gate_can_be_turned_into_a_hard_failure(self):
+        for metric in QuickAdapterRegressorV3._UNSUPPORTED_WEIGHTS_METRICS:
+            with (
+                self.subTest(metric=metric),
+                self.assertRaisesRegex(ValueError, "does not support custom weights"),
+            ):
+                QuickAdapterRegressorV3._prepare_distance_kwargs(
+                    metric,
+                    weights=np.array([0.5, 0.5]),
+                    mode=RAISE,
+                    reference_matrix=unique_rows(4),
+                )
+                with self.assertLogs(LOGGER_NAME, "WARNING") as captured:
+                    kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+                        metric,
+                        weights=np.array([0.5, 0.5]),
+                        mode=WARN,
+                        reference_matrix=unique_rows(4),
+                    )
+                self.assertNotIn("w", kwargs)
+                self.assertIn("using uniform weights", captured.output[0])
+
+    def test_a_minkowski_p_reaches_cdist_only_when_it_validates(self):
+        reference = unique_rows(4)
+        np.testing.assert_allclose(
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", p=3.0, reference_matrix=reference
+            )["p"],
+            3.0,
+            rtol=0.0,
+            atol=0.0,
+        )
+        self.assertNotIn(
+            "p",
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", reference_matrix=reference
+            ),
+        )
+        self.assertNotIn(
+            "p",
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", p=0.0, reference_matrix=reference
+            ),
+        )
+        with self.assertRaisesRegex(ValueError, "must be > 0"):
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "minkowski", p=0.0, mode=RAISE, reference_matrix=reference
+            )
+
+    def test_a_non_minkowski_metric_never_receives_a_p(self):
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "cityblock", p=3.0, reference_matrix=unique_rows(4)
+        )
+        self.assertNotIn("p", kwargs)
+
+    def test_a_standardized_metric_receives_its_variance_vector(self):
+        reference = np.array([[0.0, 0.0], [1.0, 2.0], [2.0, 1.0], [3.0, 3.0]])
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "seuclidean", reference_matrix=reference
+        )
+        self.assertEqual(set(kwargs), {"V"})
+        np.testing.assert_allclose(
+            kwargs["V"], np.var(reference, axis=0, ddof=1), rtol=DISTANCE_RTOL, atol=0.0
+        )
+
+    def test_a_mahalanobis_metric_receives_the_pseudoinverse_of_its_covariance(self):
+        reference = np.array([[0.0, 0.0], [1.0, 2.0], [2.0, 1.0], [3.0, 3.0]])
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "mahalanobis", reference_matrix=reference
+        )
+        self.assertEqual(set(kwargs), {"VI"})
+        covariance = np.cov(reference, rowvar=False, ddof=1)
+        eigenvalues = np.linalg.eigvalsh(covariance)
+        # A full-rank front has every eigenvalue well above the floor, so the spectral
+        # inverse is the ordinary matrix inverse.
+        self.assertTrue(np.all(eigenvalues > eigenvalues[-1] * np.sqrt(np.finfo(float).eps)))
+        np.testing.assert_allclose(
+            kwargs["VI"], np.linalg.inv(covariance), rtol=DISTANCE_RTOL, atol=0.0
+        )
+
+    def test_a_mahalanobis_metric_floors_a_singular_covariance_instead_of_failing(self):
+        # Collinear objectives pass the positive-variance guard yet give a rank-deficient
+        # covariance, so the plain inverse is singular. The floor keeps the keyword
+        # finite and the metric computable rather than raising on a legitimate front.
+        collinear = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]])
+        covariance = np.cov(collinear, rowvar=False, ddof=1)
+        self.assertLess(np.linalg.matrix_rank(covariance), 2)
+        kwargs = QuickAdapterRegressorV3._prepare_distance_kwargs(
+            "mahalanobis", reference_matrix=collinear
+        )
+        self.assertTrue(np.all(np.isfinite(kwargs["VI"])))
+        eigenvalues, eigenvectors = np.linalg.eigh(covariance)
+        floor = eigenvalues[-1] * np.sqrt(np.finfo(float).eps)
+        np.testing.assert_allclose(
+            kwargs["VI"],
+            (eigenvectors / np.maximum(eigenvalues, floor)) @ eigenvectors.T,
+            rtol=DISTANCE_RTOL,
+            atol=0.0,
+        )
+
+    def test_a_standardized_metric_refuses_a_front_that_cannot_support_a_covariance(self):
+        constant = np.array([[1.0, 1.0], [1.0, 1.0]])
+        with self.assertRaisesRegex(ValueError, "positive active-objective variances"):
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "seuclidean", reference_matrix=constant
+            )
+        with self.assertRaisesRegex(ValueError, "at least two rows"):
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "seuclidean", reference_matrix=np.array([[1.0, 2.0]])
+            )
+        with self.assertRaisesRegex(ValueError, "at least two rows"):
+            QuickAdapterRegressorV3._prepare_distance_kwargs(
+                "seuclidean", reference_matrix=np.array([[1.0, 2.0], [np.nan, 3.0], [3.0, 4.0]])
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_regressor_weight_support.py b/quickadapter/tests/test_regressor_weight_support.py
new file mode 100644 (file)
index 0000000..7ff08b8
--- /dev/null
@@ -0,0 +1,216 @@
+"""Training weight support gating; requires the Freqtrade QA image."""
+
+import logging
+import unittest
+
+import numpy as np
+from qa_support import QaTestCase
+from Utils import LabelWeightSupportError
+
+from quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3 import QuickAdapterRegressorV3
+
+RAISE = QuickAdapterRegressorV3._SUPPORT_POLICY_RAISE
+FALLBACK = QuickAdapterRegressorV3._SUPPORT_POLICY_FALLBACK
+NONE_STRATEGY = "none"
+NON_NONE_STRATEGY = "zigzag"
+CONTEXT = "[BTC/USDT] test"
+
+
+def config(**overrides):
+    base = {
+        "support_policy": RAISE,
+        "min_pivot_equivalent_count": 1,
+        "min_positive_label_weight_fraction": 0.0,
+        "min_effective_sample_size": 0.0,
+        "strategy": NONE_STRATEGY,
+    }
+    base.update(overrides)
+    return base
+
+
+class EvalWeightsTest(QaTestCase):
+    """Eval splits bypass support gating: routine test splits trip the thresholds by construction."""
+
+    def _compose(self, base, labels):
+        return QuickAdapterRegressorV3._compose_eval_weights(base, labels, context=CONTEXT)
+
+    def test_eval_weights_have_unit_mean(self):
+        result = self._compose(np.ones(4), np.array([3.0, 1.0, 2.0, 1.0]))
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+    def test_eval_weights_ignore_the_support_policy_entirely(self):
+        # Every training threshold is unreachable here, and nothing is raised.
+        result = self._compose(np.ones(4), np.array([1.0, 1.0, 1.0, 1.0]))
+        self.assertEqual(result.size, 4)
+
+    def test_an_all_dropped_composition_falls_back_to_base_weights(self):
+        base = np.array([1.0, 2.0, 3.0, 4.0])
+        with self.assertLogs(
+            "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3", "WARNING"
+        ):
+            result = self._compose(base, np.zeros(4))
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+    def test_a_shape_mismatch_is_a_hard_failure_not_a_support_condition(self):
+        with self.assertRaisesRegex(ValueError, "label_weights shape"):
+            self._compose(np.ones(4), np.ones(3))
+
+    def test_the_fallback_preserves_the_base_proportions(self):
+        base = np.array([1.0, 1.0, 3.0])
+        with self.assertLogs(
+            "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3", "WARNING"
+        ):
+            result = self._compose(base, np.zeros(3))
+        self.assertAlmostEqual(float(result[2] / result[0]), 3.0)
+
+
+class SupportPolicyTest(QaTestCase):
+    def _apply(self, policy, reasons=("because",)):
+        return QuickAdapterRegressorV3._apply_support_policy(
+            np.array([1.0, 3.0]), context=CONTEXT, policy=policy, reasons=list(reasons)
+        )
+
+    def test_the_raise_policy_aborts_with_the_reasons(self):
+        with self.assertRaisesRegex(ValueError, r"label weighting support failed \(because\)"):
+            self._apply(RAISE)
+
+    def test_the_fallback_policy_returns_sanitized_base_weights(self):
+        with self.assertLogs(
+            "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3", "WARNING"
+        ):
+            result = self._apply(FALLBACK)
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+        self.assertAlmostEqual(float(result[1] / result[0]), 3.0)
+
+    def test_every_reason_is_reported_not_just_the_first(self):
+        with self.assertRaisesRegex(ValueError, "first.*second.*third"):
+            self._apply(RAISE, reasons=("first", "second", "third"))
+
+
+class EnforceSupportTest(QaTestCase):
+    def _enforce(self, label_weights, sample_weights, **config_overrides):
+        return QuickAdapterRegressorV3._enforce_train_weight_support(
+            np.ones(len(label_weights)),
+            label_weights,
+            sample_weights,
+            config(**config_overrides),
+            context=CONTEXT,
+        )
+
+    def test_weights_passing_every_threshold_are_returned_unchanged(self):
+        sample = np.array([1.0, 1.0, 1.0, 1.0])
+        result = self._enforce(np.ones(4), sample, min_effective_sample_size=4.0)
+        np.testing.assert_allclose(result, sample, rtol=1e-12, atol=0.0)
+
+    def test_a_threshold_is_inclusive_at_the_boundary(self):
+        # Four equal positive label weights give four pivots, a positive fraction of
+        # exactly 1.0 and an ESS of exactly 4.0, so all three pass at their minimum.
+        result = self._enforce(
+            np.ones(4),
+            np.ones(4),
+            min_pivot_equivalent_count=4,
+            min_positive_label_weight_fraction=1.0,
+            min_effective_sample_size=4.0,
+        )
+        self.assertEqual(result.size, 4)
+
+    def test_one_ulp_below_a_threshold_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "effective_sample_size"):
+            self._enforce(np.ones(4), np.ones(4), min_effective_sample_size=4.5)
+
+    def test_too_few_pivots_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "pivot_equivalent_count"):
+            self._enforce(np.ones(4), np.ones(4), min_pivot_equivalent_count=5)
+
+    def test_too_small_a_positive_fraction_is_refused(self):
+        # Two of four rows carry a positive label weight, and only those two are pivots.
+        with self.assertRaisesRegex(ValueError, "positive_label_weight_fraction"):
+            self._enforce(
+                np.array([1.0, 0.0, 1.0, 0.0]),
+                np.ones(4),
+                min_positive_label_weight_fraction=0.75,
+            )
+
+    def test_all_three_reasons_accumulate_into_one_failure(self):
+        with self.assertRaisesRegex(
+            ValueError, "pivot_equivalent_count.*positive_label_weight_fraction"
+        ):
+            self._enforce(
+                np.array([1.0, 0.0, 1.0, 0.0]),
+                np.ones(4),
+                min_pivot_equivalent_count=3,
+                min_positive_label_weight_fraction=0.75,
+                min_effective_sample_size=4.5,
+            )
+
+    def test_the_fallback_policy_turns_a_support_failure_into_base_weights(self):
+        with self.assertLogs(
+            "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3", "WARNING"
+        ):
+            result = self._enforce(
+                np.ones(4),
+                np.ones(4),
+                support_policy=FALLBACK,
+                min_effective_sample_size=4.5,
+            )
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+
+class ComposeTrainWeightsTest(QaTestCase):
+    def _compose(self, base, labels, **config_overrides):
+        return QuickAdapterRegressorV3._compose_train_weights_with_support(
+            base, labels, config(**config_overrides), context=CONTEXT
+        )
+
+    def test_a_none_strategy_with_no_label_weights_returns_base_weights(self):
+        result = self._compose(np.array([1.0, 3.0]), None)
+        self.assertAlmostEqual(float(result[1] / result[0]), 3.0)
+
+    def test_a_configured_strategy_with_no_label_weights_is_gated_by_the_policy(self):
+        # A non-"none" strategy that produced no pivots must not silently return base
+        # weights: under "raise" it aborts, naming the strategy and the absence of pivots.
+        with self.assertRaisesRegex(ValueError, "no label weights available"):
+            self._compose(np.ones(4), None, strategy=NON_NONE_STRATEGY)
+
+    def test_the_fallback_policy_handles_a_missing_label_weighting_gracefully(self):
+        with self.assertLogs(
+            "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3", "WARNING"
+        ):
+            result = self._compose(
+                np.array([1.0, 3.0]), None, strategy=NON_NONE_STRATEGY, support_policy=FALLBACK
+            )
+        self.assertAlmostEqual(float(result[1] / result[0]), 3.0)
+
+    def test_a_composed_set_that_passes_support_is_returned(self):
+        result = self._compose(
+            np.ones(4), np.array([1.0, 1.0, 1.0, 1.0]), min_effective_sample_size=4.0
+        )
+        self.assertEqual(result.size, 4)
+
+    def test_a_composed_set_that_breaches_support_routes_through_the_policy(self):
+        with self.assertRaisesRegex(ValueError, "effective_sample_size"):
+            self._compose(np.ones(4), np.ones(4), min_effective_sample_size=4.5)
+
+    def test_an_all_dropped_composition_routes_through_the_policy(self):
+        with self.assertRaisesRegex(ValueError, "all rows dropped"):
+            self._compose(np.ones(4), np.zeros(4))
+
+    def test_an_all_dropped_composition_falls_back_under_the_fallback_policy(self):
+        with self.assertLogs(
+            "quickadapter.user_data.freqaimodels.QuickAdapterRegressorV3", "WARNING"
+        ):
+            result = self._compose(np.ones(4), np.zeros(4), support_policy=FALLBACK)
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+    def test_the_support_error_is_what_the_train_path_catches(self):
+        self.assertTrue(issubclass(LabelWeightSupportError, ValueError))
+        with self.assertRaises(LabelWeightSupportError):
+            from Utils import compose_sample_weights
+
+            compose_sample_weights(
+                np.ones(4), np.zeros(4), logger=logging.getLogger("t"), context=CONTEXT
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_strategy_features.py b/quickadapter/tests/test_strategy_features.py
new file mode 100644 (file)
index 0000000..b19ded9
--- /dev/null
@@ -0,0 +1,694 @@
+"""Candle-cache, threshold and take-profit feature contracts; requires the Freqtrade QA image."""
+
+import datetime
+import math
+import unittest
+from types import SimpleNamespace
+from unittest import mock
+
+import numpy as np
+import pandas as pd
+import QuickAdapterV3 as strategy_module
+from EnumErrors import enum_error_message
+from numpy.testing import assert_allclose
+from qa_support import PAIR, QaTestCase
+from QuickAdapterV3 import QuickAdapterV3
+
+LONG = QuickAdapterV3._TRADE_LONG
+SHORT = QuickAdapterV3._TRADE_SHORT
+DIRECT = QuickAdapterV3._INTERPOLATION_DIRECT
+INVERSE = QuickAdapterV3._INTERPOLATION_INVERSE
+FIRST_CANDLE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
+TIMEFRAME = "5m"
+MIN_FRACTION = 0.2
+MAX_FRACTION = 0.8
+MULTIPLIER = 3.0
+STEP_MINUTES = 5
+
+
+def mid_rank(window, value):
+    """The rank ``calculate_quantile`` reports, from scipy's ``kind="mean"`` convention.
+
+    ``percentileofscore(..., kind="mean")`` scores a value as the mean of the ranks
+    it spans, i.e. ``(strictly_below + 0.5 * tied) / n``, and ``calculate_quantile``
+    divides that by 100. Stated here rather than called, so the expectations below
+    rest on the documented convention instead of on the helper under test.
+    """
+    below = sum(1 for candidate in window if candidate < value)
+    tied = sum(1 for candidate in window if candidate == value)
+    return (below + 0.5 * tied) / len(window)
+
+
+def interpolated_fraction(quantile, exponent, low=MIN_FRACTION, high=MAX_FRACTION):
+    """The natr-multiplier fraction a candle of quantile rank ``quantile`` is priced at."""
+    shaped = quantile**exponent
+    return low + (high - low) * shaped
+
+
+def candles(
+    closes,
+    natr=None,
+    multiplier=None,
+    opens=None,
+    highs=None,
+    lows=None,
+    start=FIRST_CANDLE,
+):
+    """An OHLC frame keyed by ``date``, carrying the optional label-NATR columns."""
+    closes = np.asarray(closes, dtype=float)
+    count = closes.size
+    frame = pd.DataFrame(
+        {
+            "date": [start + datetime.timedelta(minutes=STEP_MINUTES * i) for i in range(count)],
+            "open": closes - 0.5 if opens is None else np.asarray(opens, dtype=float),
+            "high": closes + 1.0 if highs is None else np.asarray(highs, dtype=float),
+            "low": closes - 1.0 if lows is None else np.asarray(lows, dtype=float),
+            "close": closes,
+        }
+    )
+    if natr is not None:
+        frame["natr_label_period_candles"] = np.asarray(natr, dtype=float)
+    if multiplier is not None:
+        frame["label_natr_multiplier"] = np.full(count, float(multiplier))
+    return frame
+
+
+def strategy(label_period_candles=4, label_natr_multiplier=MULTIPLIER):
+    """A candle-cache strategy whose label parameters come from the pair, not the frame."""
+    model = object.__new__(QuickAdapterV3)
+    model._candle_deviation_cache = {}
+    model._candle_threshold_cache = {}
+    model._cached_df_signature = {}
+    model._label_params = {
+        PAIR: {
+            "label_period_candles": label_period_candles,
+            "label_natr_multiplier": label_natr_multiplier,
+        }
+    }
+    model._label_defaults = (24, 5.0)
+    model.freqai_info = {}
+    model.config = {
+        "timeframe": TIMEFRAME,
+        "exit_pricing": {"trade_natr_method": "quantile_interpolation"},
+    }
+    return model
+
+
+def trade(open_rate=100.0, is_short=False, open_candle_offset=1):
+    """A trade opened ``open_candle_offset`` candles after the frame's first candle."""
+    return SimpleNamespace(
+        pair=PAIR,
+        open_rate=float(open_rate),
+        is_short=is_short,
+        open_date_utc=FIRST_CANDLE + datetime.timedelta(minutes=STEP_MINUTES * open_candle_offset),
+    )
+
+
+class StrategyFeaturesTest(QaTestCase):
+    def deviation_of(
+        self,
+        model,
+        frame,
+        candle_idx,
+        exponent=1.5,
+        direction=DIRECT,
+        low=MIN_FRACTION,
+        high=MAX_FRACTION,
+    ):
+        """The candle deviation the strategy derives, from its own quantile and multiplier."""
+        natr = frame["natr_label_period_candles"]
+        candle_idx = QuickAdapterV3._normalize_candle_idx(len(natr), candle_idx)
+        label_period_candles = model._label_params[PAIR]["label_period_candles"]
+        window = natr.iloc[: candle_idx + 1].to_numpy()[-label_period_candles:]
+        value = natr.iloc[candle_idx]
+        quantile = mid_rank(window, value)
+        if direction == DIRECT:
+            fraction = interpolated_fraction(quantile, exponent, low, high)
+        else:
+            fraction = high - (high - low) * quantile**exponent
+        multiplier = model._label_params[PAIR]["label_natr_multiplier"]
+        return float(value) / 100.0 * multiplier * fraction
+
+    def test_a_signature_ignores_the_feature_columns(self):
+        base = candles([100.0, 101.0, 102.0, 103.0])
+        extended = base.copy()
+        extended["%-rsi-14"] = [1.0, 2.0, 3.0, 4.0]
+        extended["%-day_of_week"] = 0.25
+        self.assertEqual(QuickAdapterV3._df_signature(extended), QuickAdapterV3._df_signature(base))
+        self.assertEqual(QuickAdapterV3._df_signature(extended), (4, base["date"].iloc[-1]))
+
+    def test_a_signature_separates_a_grown_frame_from_its_prefix(self):
+        base = candles([100.0, 101.0, 102.0, 103.0])
+        grown = candles([104.0], start=FIRST_CANDLE + datetime.timedelta(minutes=3 * STEP_MINUTES))
+        self.assertNotEqual(QuickAdapterV3._df_signature(grown), QuickAdapterV3._df_signature(base))
+        self.assertEqual(
+            QuickAdapterV3._df_signature(grown),
+            (1, FIRST_CANDLE + datetime.timedelta(minutes=3 * STEP_MINUTES)),
+        )
+
+    def test_a_signature_separates_frames_whose_last_date_differs(self):
+        base = candles([100.0, 101.0, 102.0, 103.0])
+        refiled = candles(
+            [100.0, 101.0, 102.0, 999.0],
+            start=FIRST_CANDLE + datetime.timedelta(minutes=STEP_MINUTES),
+        )
+        self.assertNotEqual(
+            QuickAdapterV3._df_signature(refiled), QuickAdapterV3._df_signature(base)
+        )
+        self.assertEqual(
+            QuickAdapterV3._df_signature(refiled)[0], QuickAdapterV3._df_signature(base)[0]
+        )
+
+    def test_an_empty_or_dateless_frame_carries_no_candle(self):
+        self.assertEqual(QuickAdapterV3._df_signature(pd.DataFrame()), (0, None))
+        self.assertEqual(
+            QuickAdapterV3._df_signature(pd.DataFrame({"close": [1.0, 2.0]})), (2, None)
+        )
+        self.assertEqual(
+            QuickAdapterV3._df_signature(pd.DataFrame({"date": [], "close": []})), (0, None)
+        )
+
+    def test_an_unchanged_frame_reuses_the_cached_deviation(self):
+        model = strategy()
+        frame = candles([100.0, 101.0, 102.0, 103.0], natr=[1.0, 2.0, 3.0, 4.0])
+        counted = mock.Mock(side_effect=strategy_module.calculate_quantile)
+        with mock.patch.object(strategy_module, "calculate_quantile", counted):
+            first = model._calculate_candle_deviation(frame, PAIR, MIN_FRACTION, MAX_FRACTION)
+            second = model._calculate_candle_deviation(frame, PAIR, MIN_FRACTION, MAX_FRACTION)
+        self.assertEqual(counted.call_count, 1)
+        self.assertEqual(second, first)
+        self.assertEqual(len(model._candle_deviation_cache), 1)
+
+    def test_a_frame_that_grew_recomputes_the_deviation_and_threshold(self):
+        model = strategy()
+        frame = candles(
+            [100.0, 101.0, 102.0, 103.0], natr=[1.0, 2.0, 3.0, 4.0], multiplier=MULTIPLIER
+        )
+        stale_deviation = model._calculate_candle_deviation(frame, PAIR, MIN_FRACTION, MAX_FRACTION)
+        stale_threshold = model._calculate_candle_threshold(
+            frame, PAIR, LONG, MIN_FRACTION, MAX_FRACTION
+        )
+        grown = pd.concat(
+            [
+                frame,
+                candles(
+                    [104.0],
+                    natr=[5.0],
+                    multiplier=MULTIPLIER,
+                    start=FIRST_CANDLE + datetime.timedelta(minutes=4 * STEP_MINUTES),
+                ),
+            ],
+            ignore_index=True,
+        )
+        deviation = model._calculate_candle_deviation(grown, PAIR, MIN_FRACTION, MAX_FRACTION)
+        threshold = model._calculate_candle_threshold(grown, PAIR, LONG, MIN_FRACTION, MAX_FRACTION)
+        assert_allclose(deviation, self.deviation_of(model, grown, -1), rtol=1e-9, atol=1e-12)
+        assert_allclose(threshold, 104.0 * (1.0 + deviation), rtol=1e-9, atol=1e-12)
+        self.assertNotEqual(deviation, stale_deviation)
+        self.assertNotEqual(threshold, stale_threshold)
+        grown_signature = QuickAdapterV3._df_signature(grown)
+        self.assertEqual(model._cached_df_signature[PAIR], grown_signature)
+        self.assertEqual(
+            [key[1] for key in model._candle_deviation_cache],
+            [grown_signature],
+        )
+        self.assertEqual(
+            [key[1] for key in model._candle_threshold_cache],
+            [grown_signature],
+        )
+
+    def test_a_matching_signature_keeps_the_pair_caches(self):
+        model = strategy()
+        frame = candles([100.0, 101.0, 102.0, 103.0], natr=[1.0, 2.0, 3.0, 4.0])
+        signature = QuickAdapterV3._df_signature(frame)
+        model._calculate_candle_deviation(frame, PAIR, MIN_FRACTION, MAX_FRACTION)
+        model._calculate_candle_threshold(frame, PAIR, LONG, MIN_FRACTION, MAX_FRACTION)
+        model._invalidate_pair_caches(PAIR, signature)
+        self.assertEqual(len(model._candle_deviation_cache), 1)
+        self.assertEqual(len(model._candle_threshold_cache), 1)
+        self.assertEqual(model._cached_df_signature[PAIR], signature)
+
+    def test_invalidating_a_pair_drops_its_caches_and_signature_only(self):
+        model = strategy()
+        frame = candles([100.0, 101.0, 102.0, 103.0], natr=[1.0, 2.0, 3.0, 4.0])
+        other = "ETH/USDT"
+        model._calculate_candle_deviation(frame, PAIR, MIN_FRACTION, MAX_FRACTION)
+        model._calculate_candle_threshold(frame, PAIR, LONG, MIN_FRACTION, MAX_FRACTION)
+        signature = QuickAdapterV3._df_signature(frame)
+        model._cached_df_signature[other] = signature
+        model._candle_deviation_cache[(other, signature, 0.0, 1.0, -1, DIRECT, 1.0)] = 0.5
+        model._candle_threshold_cache[(other, signature, LONG, -1, 0.0, 1.0)] = 0.5
+        model._invalidate_pair_caches(PAIR, None)
+        self.assertEqual({key[0] for key in model._candle_deviation_cache}, {other})
+        self.assertEqual({key[0] for key in model._candle_threshold_cache}, {other})
+        self.assertNotIn(PAIR, model._cached_df_signature)
+        self.assertEqual(model._cached_df_signature[other], signature)
+
+    def test_the_deviation_follows_the_quantile_rank_of_the_candle(self):
+        model = strategy(label_period_candles=5)
+        natr = [1.0, 2.0, 3.0, 4.0, 5.0]
+        frame = candles([100.0, 101.0, 102.0, 103.0, 104.0], natr=natr)
+        # Mid-rank of the top value in a k-length strictly increasing window is (k - 0.5) / k.
+        for candle_idx, quantile in enumerate((0.5, 0.75, 2.5 / 3, 0.875, 0.9)):
+            with self.subTest(candle_idx=candle_idx):
+                deviation = model._calculate_candle_deviation(
+                    frame, PAIR, MIN_FRACTION, MAX_FRACTION, candle_idx=candle_idx
+                )
+                assert_allclose(
+                    deviation,
+                    natr[candle_idx] / 100.0 * MULTIPLIER * (0.2 + 0.6 * quantile**1.5),
+                    rtol=1e-9,
+                    atol=1e-12,
+                )
+                assert_allclose(
+                    deviation, self.deviation_of(model, frame, candle_idx), rtol=1e-9, atol=1e-12
+                )
+
+    def test_the_deviation_window_is_the_last_label_period_candles(self):
+        natr = [1.0, 2.0, 3.0, 4.0, 5.0]
+        frame = candles([100.0, 101.0, 102.0, 103.0, 104.0], natr=natr)
+        expected = {}
+        for label_period_candles in (1, 2, 5):
+            model = strategy(label_period_candles=label_period_candles)
+            expected[label_period_candles] = [
+                model._calculate_candle_deviation(
+                    frame, PAIR, MIN_FRACTION, MAX_FRACTION, candle_idx=idx
+                )
+                for idx in range(len(natr))
+            ]
+        for label_period_candles, deviations in expected.items():
+            for candle_idx, deviation in enumerate(deviations):
+                with self.subTest(label_period_candles=label_period_candles, candle_idx=candle_idx):
+                    model = strategy(label_period_candles=label_period_candles)
+                    assert_allclose(
+                        deviation,
+                        self.deviation_of(model, frame, candle_idx),
+                        rtol=1e-9,
+                        atol=1e-12,
+                    )
+        self.assertNotEqual(expected[1][-1], expected[2][-1])
+        self.assertNotEqual(expected[2][-1], expected[5][-1])
+
+    def test_the_inverse_direction_mirrors_the_direct_one(self):
+        model = strategy()
+        frame = candles([100.0, 101.0, 102.0, 103.0, 104.0], natr=[1.0, 2.0, 3.0, 4.0, 5.0])
+        for candle_idx in range(5):
+            with self.subTest(candle_idx=candle_idx):
+                direct = model._calculate_candle_deviation(
+                    frame, PAIR, MIN_FRACTION, MAX_FRACTION, candle_idx=candle_idx
+                )
+                inverse = model._calculate_candle_deviation(
+                    frame,
+                    PAIR,
+                    MIN_FRACTION,
+                    MAX_FRACTION,
+                    candle_idx=candle_idx,
+                    interpolation_direction=INVERSE,
+                )
+                natr = float(frame["natr_label_period_candles"].iloc[candle_idx])
+                quantile = mid_rank(
+                    frame["natr_label_period_candles"].to_numpy()[: candle_idx + 1][
+                        -model._label_params[PAIR]["label_period_candles"] :
+                    ],
+                    natr,
+                )
+                expected_inverse = (
+                    natr
+                    / 100.0
+                    * MULTIPLIER
+                    * (MAX_FRACTION - (MAX_FRACTION - MIN_FRACTION) * quantile**1.5)
+                )
+                assert_allclose(inverse, expected_inverse, rtol=1e-9, atol=1e-12)
+                # The two directions sum to a multiple of the candle's NATR that does not
+                # depend on its quantile rank: (natr / 100) * multiplier * (low + high).
+                assert_allclose(
+                    (direct + inverse) / MULTIPLIER,
+                    natr / 100.0 * (MIN_FRACTION + MAX_FRACTION),
+                    rtol=1e-12,
+                    atol=0.0,
+                )
+                assert_allclose(
+                    inverse,
+                    self.deviation_of(model, frame, candle_idx, direction=INVERSE),
+                    rtol=1e-9,
+                    atol=1e-12,
+                )
+
+    def test_the_quantile_exponent_shapes_the_interpolation(self):
+        model = strategy()
+        frame = candles([100.0, 101.0, 102.0, 103.0], natr=[1.0, 2.0, 3.0, 4.0])
+        shaped = {}
+        for exponent in (0.5, 1.0, 1.5, 2.0):
+            shaped[exponent] = model._calculate_candle_deviation(
+                frame, PAIR, MIN_FRACTION, MAX_FRACTION, quantile_exponent=exponent
+            )
+            self.assertEqual(len(model._candle_deviation_cache), len(shaped))
+        self.assertEqual(len(set(shaped.values())), len(shaped))
+        for exponent, deviation in shaped.items():
+            with self.subTest(exponent=exponent):
+                assert_allclose(
+                    deviation,
+                    self.deviation_of(model, frame, -1, exponent=exponent),
+                    rtol=1e-9,
+                    atol=1e-12,
+                )
+        # Exponent 2 on the top rank of a four-value window (7/8) is the exact rational 49/64.
+        assert_allclose(shaped[2.0], 0.12 * (0.2 + 0.6 * 0.765625), rtol=1e-9, atol=1e-12)
+        assert_allclose(shaped[1.0], 0.12 * (0.2 + 0.6 * 0.875), rtol=1e-9, atol=1e-12)
+
+    def test_an_unknown_interpolation_direction_is_refused(self):
+        model = strategy()
+        frame = candles([100.0, 101.0, 102.0, 103.0], natr=[1.0, 2.0, 3.0, 4.0])
+        with self.assertRaises(ValueError) as raised:
+            model._calculate_candle_deviation(
+                frame, PAIR, MIN_FRACTION, MAX_FRACTION, interpolation_direction="sideways"
+            )
+        self.assertEqual(
+            str(raised.exception),
+            enum_error_message(
+                "interpolation_direction", "sideways", QuickAdapterV3._INTERPOLATION_DIRECTIONS
+            ),
+        )
+
+    def test_a_with_trend_candle_is_thresholded_from_its_close(self):
+        # Long + bullish and short + bearish are with-trend: the base is the close itself.
+        for side, closes, opens in ((LONG, [110.0], [100.0]), (SHORT, [100.0], [110.0])):
+            with self.subTest(side=side):
+                model = strategy(label_period_candles=1)
+                frame = candles(
+                    closes,
+                    opens=opens,
+                    highs=[112.0],
+                    lows=[99.0],
+                    natr=[1.0],
+                    multiplier=MULTIPLIER,
+                )
+                threshold = model._calculate_candle_threshold(frame, PAIR, side, 0.0, 1.0)
+                deviation = model._calculate_candle_deviation(frame, PAIR, 0.0, 1.0)
+                assert_allclose(
+                    threshold,
+                    110.0 * (1 + deviation) if side == LONG else 100.0 * (1 - deviation),
+                    rtol=1e-9,
+                    atol=1e-12,
+                )
+                self.assertNotAlmostEqual(
+                    threshold,
+                    QuickAdapterV3.weighted_close(frame.iloc[0]) * (1 + deviation)
+                    if side == LONG
+                    else QuickAdapterV3.weighted_close(frame.iloc[0]) * (1 - deviation),
+                    delta=1e-6,
+                )
+
+    def test_an_adverse_candle_is_thresholded_from_its_weighted_close(self):
+        for side, closes, opens in ((LONG, [100.0], [110.0]), (SHORT, [110.0], [100.0])):
+            with self.subTest(side=side):
+                model = strategy(label_period_candles=1)
+                frame = candles(
+                    closes,
+                    opens=opens,
+                    highs=[112.0],
+                    lows=[99.0],
+                    natr=[1.0],
+                    multiplier=MULTIPLIER,
+                )
+                threshold = model._calculate_candle_threshold(frame, PAIR, side, 0.0, 1.0)
+                deviation = model._calculate_candle_deviation(frame, PAIR, 0.0, 1.0)
+                weighted_close = QuickAdapterV3.weighted_close(frame.iloc[0])
+                self.assertNotAlmostEqual(weighted_close, float(closes[0]))
+                assert_allclose(
+                    threshold,
+                    weighted_close * (1 + deviation)
+                    if side == LONG
+                    else weighted_close * (1 - deviation),
+                    rtol=1e-9,
+                    atol=1e-12,
+                )
+
+    def test_an_unknown_side_is_refused(self):
+        model = strategy(label_period_candles=1)
+        frame = candles([110.0], opens=[100.0], natr=[1.0], multiplier=MULTIPLIER)
+        with self.assertRaises(ValueError) as raised:
+            model._calculate_candle_threshold(frame, PAIR, "sideways", 0.0, 1.0)
+        self.assertEqual(
+            str(raised.exception),
+            enum_error_message("side", "sideways", QuickAdapterV3._TRADE_DIRECTIONS),
+        )
+
+    def test_an_unpriceable_candle_is_refused_and_never_memoised(self):
+        model = strategy(label_period_candles=1)
+        flat = candles(
+            [110.0], opens=[100.0], highs=[112.0], lows=[99.0], natr=[0.0], multiplier=MULTIPLIER
+        )
+        self.assertEqual(model._calculate_candle_deviation(flat, PAIR, 0.0, 1.0), 0.0)
+        for side in (LONG, SHORT):
+            with self.subTest(zero_volatility=side):
+                self.assertTrue(
+                    np.isnan(model._calculate_candle_threshold(flat, PAIR, side, 0.0, 1.0))
+                )
+
+        # Every probe below needs a signature the cache has not seen, because the memoised
+        # entry of an earlier frame shadows the branch under test: same-signature frames
+        # share one cache entry, whatever their values are.
+        def unreadable_candle(offset, natr):
+            return candles(
+                [110.0],
+                opens=[100.0],
+                highs=[112.0],
+                lows=[99.0],
+                natr=[natr],
+                multiplier=MULTIPLIER,
+                start=FIRST_CANDLE + datetime.timedelta(minutes=STEP_MINUTES * offset),
+            )
+
+        for offset, natr in ((1, np.nan), (2, -1.0)):
+            with self.subTest(natr=natr):
+                deviation = model._calculate_candle_deviation(
+                    unreadable_candle(offset, natr), PAIR, 0.0, 1.0
+                )
+                self.assertTrue(np.isnan(deviation))
+        for offset, column in ((3, "close"), (4, "open")):
+            holed = unreadable_candle(offset, 1.0)
+            holed.loc[0, column] = np.nan
+            for side in (LONG, SHORT):
+                with self.subTest(missing=column, side=side):
+                    self.assertTrue(
+                        np.isnan(model._calculate_candle_threshold(holed, PAIR, side, 0.0, 1.0))
+                    )
+        # A refusal is not a value: only the zero-volatility deviation is memoised, while
+        # every unreadable deviation and threshold leaves no entry behind.
+        self.assertEqual(len(model._candle_deviation_cache), 1)
+        self.assertEqual(model._candle_threshold_cache, {})
+
+    def test_the_weighted_close_lies_between_the_low_and_the_high(self):
+        candle = candles([110.0], opens=[100.0], highs=[120.0], lows=[95.0]).iloc[0]
+        for weight in (0.0, 0.5, 1.0, 2.0, 5.0):
+            with self.subTest(weight=weight):
+                value = QuickAdapterV3.weighted_close(candle, weight)
+                self.assertGreater(value, float(candle["low"]))
+                self.assertLess(value, float(candle["high"]))
+                assert_allclose(
+                    value,
+                    (candle["high"] + candle["low"] + weight * candle["close"]) / (2.0 + weight),
+                    rtol=1e-9,
+                    atol=1e-9,
+                )
+
+    def test_the_default_weight_pulls_the_midpoint_towards_the_close(self):
+        # Close 100 sits below the 105.5 midpoint of the 99/112 range, so the default weight
+        # of 2.0 has to move the value down from the midpoint without reaching the close.
+        candle = candles([100.0], opens=[110.0], highs=[112.0], lows=[99.0]).iloc[0]
+        midpoint = QuickAdapterV3.weighted_close(candle, 0.0)
+        weighted = QuickAdapterV3.weighted_close(candle)
+        assert_allclose(midpoint, 105.5, rtol=1e-9, atol=1e-9)
+        assert_allclose(weighted, (112.0 + 99.0 + 2.0 * 100.0) / 4.0, rtol=1e-9, atol=1e-9)
+        self.assertLess(weighted, midpoint)
+        self.assertGreater(weighted, float(candle["close"]))
+        heavier = QuickAdapterV3.weighted_close(candle, 10.0)
+        self.assertLess(heavier, weighted)
+        self.assertGreater(heavier, float(candle["close"]))
+
+    def test_each_exit_stage_maps_to_its_natr_fraction(self):
+        model = strategy()
+        frame = candles([100.0, 100.0, 100.0], natr=[1.0, 4.0, 8.0], multiplier=MULTIPLIER)
+        long_trade = trade()
+        # One candle of duration, a two-value trade NATR window and multiplier 3.0 make the
+        # distance open_rate * 0.07 * 3.0 * fraction * log10(10), and log10(10) is exactly 1.
+        for stage, params in sorted(QuickAdapterV3.partial_exit_stages.items()):
+            with self.subTest(stage=stage):
+                fraction = params[0]
+                self.assertIsInstance(fraction, float)
+                distance = 100.0 * 0.07 * MULTIPLIER * fraction
+                target = model.get_take_profit_target(frame, long_trade, stage)
+                self.assertIsNotNone(target)
+                assert_allclose(target[0], 100.0 + distance, rtol=1e-9, atol=1e-9)
+                assert_allclose(target[1], distance, rtol=1e-9, atol=1e-9)
+        for stage in (QuickAdapterV3._FINAL_EXIT_STAGE, 99, -1):
+            with self.subTest(final_stage=stage):
+                target = model.get_take_profit_target(frame, long_trade, stage)
+                assert_allclose(
+                    target[0],
+                    100.0 * (1 + 0.07 * MULTIPLIER * QuickAdapterV3._FINAL_EXIT_STAGE_PARAMS[0]),
+                    rtol=1e-9,
+                    atol=1e-9,
+                )
+        stages = [
+            model.get_take_profit_target(frame, long_trade, stage)[1]
+            for stage in (0, 1, 2, QuickAdapterV3._FINAL_EXIT_STAGE)
+        ]
+        self.assertEqual(stages, sorted(stages))
+
+    def test_a_short_take_profit_target_flips_the_sign(self):
+        model = strategy()
+        frame = candles([100.0, 100.0, 100.0], natr=[1.0, 4.0, 8.0], multiplier=MULTIPLIER)
+        long_target = model.get_take_profit_target(frame, trade(), 1)
+        short_target = model.get_take_profit_target(frame, trade(is_short=True), 1)
+        assert_allclose(short_target[0], 2 * 100.0 - long_target[0], rtol=1e-9, atol=1e-9)
+        self.assertEqual(short_target[1], long_target[1])
+        self.assertLess(short_target[0], 100.0)
+
+    def test_a_target_that_would_equal_the_open_rate_is_nudged_off_it(self):
+        model = strategy()
+        frame = candles([100.0, 100.0, 100.0], natr=[0.0, 1e-20, 2e-20], multiplier=MULTIPLIER)
+        long_target = model.get_take_profit_target(frame, trade(), 0)
+        self.assertGreater(long_target[1], 0.0)
+        self.assertEqual(long_target[0], math.nextafter(100.0, math.inf))
+        short_target = model.get_take_profit_target(frame, trade(is_short=True), 0)
+        self.assertGreater(short_target[1], 0.0)
+        self.assertEqual(short_target[0], math.nextafter(100.0, 0.0))
+
+    def test_a_non_positive_take_profit_target_is_refused(self):
+        model = strategy()
+        healthy = candles([100.0, 100.0, 100.0], natr=[1.0, 4.0, 8.0], multiplier=MULTIPLIER)
+        long_trade = trade()
+        self.assertIsNone(
+            model.get_take_profit_target(
+                healthy.drop(columns=["natr_label_period_candles"]), long_trade, 0
+            )
+        )
+        self.assertIsNone(
+            model.get_take_profit_target(
+                candles(
+                    [100.0, 100.0, 100.0], natr=[np.nan, np.nan, np.nan], multiplier=MULTIPLIER
+                ),
+                long_trade,
+                0,
+            )
+        )
+        self.assertIsNone(
+            model.get_take_profit_target(
+                candles([100.0, 100.0, 100.0], natr=[0.0, 0.0, 0.0], multiplier=MULTIPLIER),
+                long_trade,
+                0,
+            )
+        )
+        self.assertIsNone(model.get_take_profit_target(healthy, trade(open_candle_offset=2), 0))
+        underwater = model.get_take_profit_target(
+            candles([100.0, 100.0, 100.0], natr=[1.0, 400.0, 800.0], multiplier=MULTIPLIER),
+            trade(open_rate=1e-9, is_short=True),
+            QuickAdapterV3._FINAL_EXIT_STAGE,
+        )
+        self.assertIsNone(underwater)
+
+    def test_a_candle_index_is_addressable_by_position_or_by_timestamp(self):
+        frame = candles([100.0, 101.0, 102.0, 103.0, 104.0]).set_index("date")
+        for position, candle_date in enumerate(frame.index):
+            with self.subTest(candle_date=candle_date):
+                positional = frame.index.get_loc(candle_date)
+                self.assertEqual(positional, position)
+                self.assertEqual(
+                    QuickAdapterV3._normalize_candle_idx(len(frame), positional),
+                    QuickAdapterV3._normalize_candle_idx(len(frame), position),
+                )
+                self.assertEqual(
+                    QuickAdapterV3._normalize_candle_idx(len(frame), position - len(frame)),
+                    position,
+                )
+        self.assertEqual(
+            QuickAdapterV3._normalize_candle_idx(len(frame), frame.index.get_loc(frame.index[-1])),
+            QuickAdapterV3._normalize_candle_idx(len(frame), -1),
+        )
+
+    def test_a_candle_index_is_clamped_to_the_frame(self):
+        for length, idx, expected in (
+            (0, -1, 0),
+            (0, 7, 0),
+            (5, -1, 4),
+            (5, -5, 0),
+            (5, -6, 0),
+            (5, 0, 0),
+            (5, 4, 4),
+            (5, 99, 4),
+            (1, -1, 0),
+            (1, 1, 0),
+        ):
+            with self.subTest(length=length, idx=idx):
+                self.assertEqual(QuickAdapterV3._normalize_candle_idx(length, idx), expected)
+
+    def test_the_standard_features_scale_the_weekday_and_hour_into_the_unit_interval(self):
+        model = object.__new__(QuickAdapterV3)
+        dates = [
+            datetime.datetime(2026, 1, 5, 0, 0, tzinfo=datetime.UTC),
+            datetime.datetime(2026, 1, 11, 23, 55, tzinfo=datetime.UTC),
+        ]
+        frame = pd.DataFrame({"date": dates, "close": [1.0, 2.0]})
+        result = model.feature_engineering_standard(frame, {})
+        self.assertIs(result, frame)
+        self.assertEqual(list(result.columns), ["date", "close", "%-day_of_week", "%-hour_of_day"])
+        assert_allclose(result["%-day_of_week"].to_numpy(), [1.0 / 7.0, 1.0], rtol=1e-12, atol=0.0)
+        assert_allclose(
+            result["%-hour_of_day"].to_numpy(), [1.0 / 25.0, 0.96], rtol=1e-12, atol=0.0
+        )
+        for column in ("%-day_of_week", "%-hour_of_day"):
+            self.assertTrue(result[column].between(0.0, 1.0).all())
+
+    def test_the_standard_features_read_epoch_millisecond_dates(self):
+        model = object.__new__(QuickAdapterV3)
+        milliseconds = [
+            int(datetime.datetime(2026, 1, 5, 0, 0, tzinfo=datetime.UTC).timestamp() * 1000),
+            int(datetime.datetime(2026, 1, 11, 23, 55, tzinfo=datetime.UTC).timestamp() * 1000),
+        ]
+        frame = pd.DataFrame({"date": pd.Series(milliseconds, dtype="int64"), "close": [1.0, 2.0]})
+        result = model.feature_engineering_standard(frame, {})
+        assert_allclose(result["%-day_of_week"].to_numpy(), [1.0 / 7.0, 1.0], rtol=1e-12, atol=0.0)
+        assert_allclose(
+            result["%-hour_of_day"].to_numpy(), [1.0 / 25.0, 0.96], rtol=1e-12, atol=0.0
+        )
+
+    def test_a_malformed_date_column_is_refused(self):
+        model = object.__new__(QuickAdapterV3)
+        frame = pd.DataFrame({"date": pd.Series([7, 8], dtype="int64"), "close": [1.0, 2.0]})
+        with self.assertRaises(ValueError) as raised:
+            model.feature_engineering_standard(frame, {})
+        self.assertIn("outside the expected epoch-ms range", str(raised.exception))
+
+    def test_an_iso_string_is_recognised_only_when_it_parses(self):
+        for value in ("2026-01-01", "2026-01-01T00:00:00+00:00", "2026-01-01 00:00:00"):
+            with self.subTest(value=value):
+                self.assertTrue(QuickAdapterV3.is_isoformat(value))
+        for value in ("", "not-a-date", "2026-13-01", 20260101, None, b"2026-01-01", object()):
+            with self.subTest(value=value):
+                self.assertFalse(QuickAdapterV3.is_isoformat(value))
+
+    def test_a_timedelta_is_formatted_with_its_sign(self):
+        for delta, expected in (
+            (datetime.timedelta(0), "0:00:00:00"),
+            (datetime.timedelta(minutes=5), "0:00:05:00"),
+            (datetime.timedelta(hours=1, minutes=2, seconds=3), "0:01:02:03"),
+            (datetime.timedelta(days=2, hours=3), "2:03:00:00"),
+            (datetime.timedelta(minutes=-5, seconds=-7), "-0:00:05:07"),
+        ):
+            with self.subTest(delta=delta):
+                self.assertEqual(QuickAdapterV3._td_format(delta), expected)
+
+    def test_a_timedelta_format_reports_an_unusable_pattern(self):
+        self.assertEqual(
+            QuickAdapterV3._td_format(datetime.timedelta(seconds=90), "{m}:{s}"), "1:30"
+        )
+        with self.assertRaises(ValueError) as raised:
+            QuickAdapterV3._td_format(datetime.timedelta(seconds=1), "{bogus}")
+        self.assertIn("Invalid pattern value '{bogus}'", str(raised.exception))
+        self.assertIsInstance(raised.exception.__cause__, KeyError)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_strategy_pure.py b/quickadapter/tests/test_strategy_pure.py
new file mode 100644 (file)
index 0000000..5bfea67
--- /dev/null
@@ -0,0 +1,295 @@
+"""Final take-profit state machine contracts; requires the Freqtrade QA image."""
+
+import datetime
+import unittest
+
+import numpy as np
+from qa_support import QaTestCase
+from QuickAdapterV3 import QuickAdapterV3
+
+LONG = QuickAdapterV3._TRADE_LONG
+SHORT = QuickAdapterV3._TRADE_SHORT
+TIMEFRAME = "5m"
+CANDLE = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+
+
+def later(minutes: int) -> datetime.datetime:
+    return CANDLE + datetime.timedelta(minutes=minutes)
+
+
+def state(**overrides):
+    """Build a valid final take-profit state, then apply the caller's overrides."""
+    base = QuickAdapterV3._build_final_take_profit_state(
+        exit_stage=2,
+        trade_direction=LONG,
+        current_rate=100.0,
+        take_profit_distance=10.0,
+        retracement_fraction=0.5,
+        candle_date=CANDLE,
+        timeframe=TIMEFRAME,
+    )
+    assert base is not None
+    base.update(overrides)
+    return base
+
+
+class CandleDateTest(QaTestCase):
+    def test_an_iso_string_is_parsed_as_utc(self):
+        self.assertEqual(QuickAdapterV3._as_utc_candle_date("2026-01-01T12:00:00+00:00"), CANDLE)
+
+    def test_an_offset_timestamp_is_converted_to_utc(self):
+        offset = datetime.datetime(
+            2026, 1, 1, 14, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=2))
+        )
+        self.assertEqual(QuickAdapterV3._as_utc_candle_date(offset), CANDLE)
+
+    def test_a_naive_timestamp_is_refused(self):
+        self.assertIsNone(QuickAdapterV3._as_utc_candle_date(datetime.datetime(2026, 1, 1, 12, 0)))
+
+    def test_a_malformed_string_is_refused(self):
+        self.assertIsNone(QuickAdapterV3._as_utc_candle_date("not-a-date"))
+
+    def test_a_non_timestamp_value_is_refused(self):
+        for value in (None, 42, object()):
+            with self.subTest(value=type(value).__name__):
+                self.assertIsNone(QuickAdapterV3._as_utc_candle_date(value))
+
+    def test_alignment_requires_an_exact_timeframe_boundary(self):
+        self.assertTrue(QuickAdapterV3._is_candle_date_aligned(CANDLE, TIMEFRAME))
+        self.assertFalse(
+            QuickAdapterV3._is_candle_date_aligned(
+                CANDLE + datetime.timedelta(minutes=1), TIMEFRAME
+            )
+        )
+
+    def test_alignment_refuses_a_missing_date_or_timeframe(self):
+        for candle, timeframe in ((None, TIMEFRAME), (CANDLE, ""), (CANDLE, None), (CANDLE, 5)):
+            with self.subTest(candle=candle, timeframe=timeframe):
+                self.assertFalse(QuickAdapterV3._is_candle_date_aligned(candle, timeframe))
+
+
+class RetracementDistanceTest(QaTestCase):
+    def _normalize(self, best_rate, retracement, direction):
+        return QuickAdapterV3._normalize_final_take_profit_retracement_distance(
+            best_rate=best_rate, retracement_distance=retracement, trade_direction=direction
+        )
+
+    def test_a_long_boundary_sits_below_the_best_rate(self):
+        self.assertEqual(self._normalize(100.0, 5.0, LONG), 5.0)
+
+    def test_a_short_boundary_sits_above_the_best_rate(self):
+        self.assertEqual(self._normalize(100.0, 5.0, SHORT), 5.0)
+
+    def test_a_zero_distance_is_nudged_to_one_ulp(self):
+        distance = self._normalize(100.0, 0.0, LONG)
+        self.assertGreater(distance, 0.0)
+        self.assertEqual(100.0 - distance, float(np.nextafter(100.0, 0.0)))
+
+    def test_a_distance_wider_than_the_best_rate_is_refused(self):
+        self.assertIsNone(self._normalize(100.0, 150.0, LONG))
+
+    def test_invalid_inputs_are_refused(self):
+        for best_rate, retracement, direction in (
+            (np.nan, 1.0, LONG),
+            (0.0, 1.0, LONG),
+            (-1.0, 1.0, LONG),
+            (100.0, np.inf, LONG),
+            (100.0, -1.0, LONG),
+            (100.0, 1.0, "sideways"),
+        ):
+            with self.subTest(best=best_rate, distance=retracement, direction=direction):
+                self.assertIsNone(self._normalize(best_rate, retracement, direction))
+
+
+class StateBuilderTest(QaTestCase):
+    def _build(self, **overrides):
+        arguments = {
+            "exit_stage": 2,
+            "trade_direction": LONG,
+            "current_rate": 100.0,
+            "take_profit_distance": 10.0,
+            "retracement_fraction": 0.5,
+            "candle_date": CANDLE,
+            "timeframe": TIMEFRAME,
+        }
+        arguments.update(overrides)
+        return QuickAdapterV3._build_final_take_profit_state(**arguments)
+
+    def test_a_valid_input_produces_a_usable_state(self):
+        built = self._build()
+        self.assertIsNotNone(built)
+        self.assertEqual(built["version"], QuickAdapterV3._FINAL_TAKE_PROFIT_STATE_VERSION)
+        self.assertEqual(built["exit_stage"], 2)
+        self.assertEqual(built["best_rate"], 100.0)
+        self.assertEqual(built["retracement_distance"], 5.0)
+        self.assertIsNone(built["trigger_candle_date"])
+
+    def test_the_retracement_is_the_fraction_of_the_take_profit_distance(self):
+        for fraction in (0.1, 0.5, 1.0):
+            with self.subTest(fraction=fraction):
+                self.assertAlmostEqual(
+                    self._build(retracement_fraction=fraction)["retracement_distance"],
+                    10.0 * fraction,
+                )
+
+    def test_a_boolean_exit_stage_is_refused(self):
+        self.assertIsNone(self._build(exit_stage=True))
+
+    def test_a_negative_exit_stage_is_refused(self):
+        self.assertIsNone(self._build(exit_stage=-1))
+
+    def test_a_fraction_outside_the_half_open_range_is_refused(self):
+        for fraction in (0.0, -0.1, 1.0001, np.nan):
+            with self.subTest(fraction=fraction):
+                self.assertIsNone(self._build(retracement_fraction=fraction))
+
+    def test_a_misaligned_candle_date_is_refused(self):
+        self.assertIsNone(self._build(candle_date=CANDLE + datetime.timedelta(minutes=1)))
+        self.assertIsNone(self._build(candle_date=None))
+
+    def test_a_non_positive_rate_or_distance_is_refused(self):
+        for rate, distance in ((0.0, 10.0), (-1.0, 10.0), (100.0, 0.0), (100.0, -1.0)):
+            with self.subTest(rate=rate, distance=distance):
+                self.assertIsNone(self._build(current_rate=rate, take_profit_distance=distance))
+
+    def test_an_unknown_direction_is_refused(self):
+        self.assertIsNone(self._build(trade_direction="sideways"))
+
+
+class BoundaryTest(QaTestCase):
+    def test_a_long_boundary_is_below_the_best_rate(self):
+        self.assertEqual(QuickAdapterV3._final_take_profit_boundary(state()), 95.0)
+
+    def test_a_short_boundary_is_above_the_best_rate(self):
+        short = state(trade_direction=SHORT, best_rate=100.0, retracement_distance=5.0)
+        self.assertEqual(QuickAdapterV3._final_take_profit_boundary(short), 105.0)
+
+    def test_validity_requires_a_boundary_on_the_favourable_side(self):
+        self.assertTrue(QuickAdapterV3._is_valid_final_take_profit_boundary(state()))
+        self.assertFalse(
+            QuickAdapterV3._is_valid_final_take_profit_boundary(
+                state(best_rate=100.0, retracement_distance=150.0)
+            )
+        )
+
+    def test_a_non_finite_best_rate_is_refused(self):
+        self.assertFalse(
+            QuickAdapterV3._is_valid_final_take_profit_boundary(state(best_rate=np.nan))
+        )
+
+
+class AdvanceStateTest(QaTestCase):
+    def test_a_long_best_rate_ratchets_upwards_only(self):
+        current = state()
+        minute = 5
+        for rate, expected in ((99.0, 100.0), (101.0, 101.0), (98.0, 101.0)):
+            with self.subTest(rate=rate):
+                _, _, changed = QuickAdapterV3._advance_final_take_profit_state(
+                    current, current_rate=rate, candle_date=later(minute)
+                )
+                self.assertTrue(changed)
+                self.assertEqual(current["best_rate"], expected)
+                minute += 5
+
+    def test_a_short_best_rate_ratchets_downwards_only(self):
+        current = state(trade_direction=SHORT, best_rate=100.0, retracement_distance=5.0)
+        minute = 5
+        for rate, expected in ((101.0, 100.0), (99.0, 99.0), (102.0, 99.0)):
+            with self.subTest(rate=rate):
+                QuickAdapterV3._advance_final_take_profit_state(
+                    current, current_rate=rate, candle_date=later(minute)
+                )
+                self.assertEqual(current["best_rate"], expected)
+                minute += 5
+
+    def test_the_retracement_distance_stays_absolute_while_the_boundary_travels(self):
+        current = state()
+        QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=110.0, candle_date=later(5)
+        )
+        self.assertEqual(current["retracement_distance"], 5.0)
+        self.assertEqual(QuickAdapterV3._final_take_profit_boundary(current), 105.0)
+
+    def test_a_repeat_on_the_same_candle_is_a_no_op(self):
+        current = state()
+        before = QuickAdapterV3._final_take_profit_boundary(current)
+        boundary, should_exit, changed = QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=50.0, candle_date=CANDLE
+        )
+        self.assertEqual(boundary, before)
+        self.assertFalse(should_exit)
+        self.assertFalse(changed)
+        self.assertEqual(current["best_rate"], 100.0)
+
+    def test_a_candle_going_backwards_is_refused(self):
+        current = state()
+        QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=110.0, candle_date=later(5)
+        )
+        _, should_exit, changed = QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=120.0, candle_date=CANDLE
+        )
+        self.assertFalse(should_exit)
+        self.assertFalse(changed)
+        self.assertEqual(current["best_rate"], 110.0)
+
+    def test_an_invalid_rate_leaves_the_state_untouched(self):
+        for rate in (0.0, -1.0, np.nan):
+            with self.subTest(rate=rate):
+                current = state()
+                _, should_exit, changed = QuickAdapterV3._advance_final_take_profit_state(
+                    current, current_rate=rate, candle_date=later(5)
+                )
+                self.assertFalse(should_exit)
+                self.assertFalse(changed)
+                self.assertEqual(current["best_rate"], 100.0)
+
+    def test_a_long_exit_triggers_at_or_below_the_boundary(self):
+        for rate, expected in ((95.0, True), (94.0, True), (95.1, False)):
+            with self.subTest(rate=rate):
+                current = state()
+                _, should_exit, _ = QuickAdapterV3._advance_final_take_profit_state(
+                    current, current_rate=rate, candle_date=later(5)
+                )
+                self.assertIs(should_exit, expected)
+
+    def test_a_short_exit_triggers_at_or_above_the_boundary(self):
+        for rate, expected in ((105.0, True), (106.0, True), (104.9, False)):
+            with self.subTest(rate=rate):
+                current = state(trade_direction=SHORT, best_rate=100.0, retracement_distance=5.0)
+                _, should_exit, _ = QuickAdapterV3._advance_final_take_profit_state(
+                    current, current_rate=rate, candle_date=later(5)
+                )
+                self.assertIs(should_exit, expected)
+
+    def test_a_trigger_latches_and_refuses_to_re_arm(self):
+        current = state()
+        _, should_exit, _ = QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=90.0, candle_date=later(5)
+        )
+        self.assertTrue(should_exit)
+        self.assertIsNotNone(current["trigger_candle_date"])
+
+        boundary, second_exit, changed = QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=120.0, candle_date=later(10)
+        )
+        self.assertTrue(second_exit)
+        self.assertFalse(changed)
+        self.assertEqual(current["best_rate"], 100.0)
+        self.assertEqual(QuickAdapterV3._final_take_profit_boundary(current), boundary)
+
+    def test_the_boundary_candle_advances_only_when_the_best_rate_moves(self):
+        current = state()
+        first_boundary_candle = current["boundary_candle_date"]
+        QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=99.0, candle_date=later(5)
+        )
+        self.assertEqual(current["boundary_candle_date"], first_boundary_candle)
+        QuickAdapterV3._advance_final_take_profit_state(
+            current, current_rate=105.0, candle_date=later(10)
+        )
+        self.assertNotEqual(current["boundary_candle_date"], first_boundary_candle)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_strategy_reversal.py b/quickadapter/tests/test_strategy_reversal.py
new file mode 100644 (file)
index 0000000..3fd8cb4
--- /dev/null
@@ -0,0 +1,312 @@
+"""Reversal confirmation gate contracts; requires the Freqtrade QA image."""
+
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import QaTestCase
+from QuickAdapterV3 import QuickAdapterV3
+
+LONG = QuickAdapterV3._TRADE_LONG
+SHORT = QuickAdapterV3._TRADE_SHORT
+ENTRY = QuickAdapterV3._ORDER_ENTRY
+EXIT = QuickAdapterV3._ORDER_EXIT
+PAIR = "BTC/USDT"
+NAN = float("nan")
+
+
+def strategy(thresholds):
+    """A strategy whose threshold collaborator replays a scripted sequence and records its inputs.
+
+    The threshold's own arithmetic is covered alongside the candle caches; here it is a
+    collaborator, so each case states the threshold it reasons about and the recorded calls
+    let the decay schedule be asserted.
+    """
+    model = object.__new__(QuickAdapterV3)
+    state = {"pending": list(thresholds), "calls": []}
+
+    def threshold(
+        df, pair, side, *, min_natr_multiplier_fraction, max_natr_multiplier_fraction, candle_idx
+    ):
+        pending = state["pending"]
+        value = pending.pop(0) if pending else NAN
+        state["calls"].append(
+            {
+                "candle_idx": candle_idx,
+                "min": min_natr_multiplier_fraction,
+                "max": max_natr_multiplier_fraction,
+            }
+        )
+        return value
+
+    model._calculate_candle_threshold = threshold
+    return model, state
+
+
+def frame(closes):
+    return pd.DataFrame({"close": np.array(closes, dtype=float)})
+
+
+class ReversalConfirmationTest(QaTestCase):
+    def _confirm(self, model, df, *, side, order, rate, lookback=0, decay=1.0, lo=0.5, hi=1.0):
+        return model.reversal_confirmed(
+            df,
+            PAIR,
+            side,
+            order,
+            rate,
+            lookback_period_candles=lookback,
+            decay_fraction=decay,
+            min_natr_multiplier_fraction=lo,
+            max_natr_multiplier_fraction=hi,
+        )
+
+    # --- the current candle alone -------------------------------------------------
+    def test_a_long_entry_breaks_strictly_upwards(self):
+        for rate, expected in ((101.0, True), (100.0, False), (99.0, False)):
+            with self.subTest(rate=rate):
+                model, _ = strategy([100.0])
+                self.assertIs(
+                    self._confirm(model, frame([100.0] * 3), side=LONG, order=ENTRY, rate=rate),
+                    expected,
+                )
+
+    def test_a_short_entry_breaks_strictly_downwards(self):
+        for rate, expected in ((99.0, True), (100.0, False), (101.0, False)):
+            with self.subTest(rate=rate):
+                model, _ = strategy([100.0])
+                self.assertIs(
+                    self._confirm(model, frame([100.0] * 3), side=SHORT, order=ENTRY, rate=rate),
+                    expected,
+                )
+
+    # --- refusals ----------------------------------------------------------------
+    def test_an_empty_frame_fails_closed(self):
+        model, _ = strategy([100.0])
+        self.assertFalse(self._confirm(model, frame([]), side=LONG, order=ENTRY, rate=101.0))
+
+    def test_an_unknown_side_or_order_fails_closed(self):
+        for side, order in (("sideways", ENTRY), (LONG, "adjust"), (None, ENTRY)):
+            with self.subTest(side=side, order=order):
+                model, _ = strategy([100.0])
+                self.assertFalse(
+                    self._confirm(model, frame([100.0] * 3), side=side, order=order, rate=101.0)
+                )
+
+    def test_a_non_finite_or_non_numeric_rate_fails_closed(self):
+        for rate in (np.nan, np.inf, -np.inf, "101.0", None):
+            with self.subTest(rate=rate):
+                model, _ = strategy([100.0])
+                self.assertFalse(
+                    self._confirm(model, frame([100.0] * 3), side=LONG, order=ENTRY, rate=rate)
+                )
+
+    def test_an_inverted_multiplier_range_fails_closed(self):
+        for lo, hi in ((1.0, 0.5), (-0.1, 1.0), (np.nan, 1.0), (0.5, np.inf)):
+            with self.subTest(lo=lo, hi=hi):
+                model, _ = strategy([100.0])
+                self.assertFalse(
+                    self._confirm(
+                        model, frame([100.0] * 3), side=LONG, order=ENTRY, rate=101.0, lo=lo, hi=hi
+                    )
+                )
+
+    def test_a_decay_fraction_outside_the_half_open_range_fails_closed(self):
+        for decay in (0.0, -0.1, 1.0001, "0.5"):
+            with self.subTest(decay=decay):
+                model, _ = strategy([100.0])
+                self.assertFalse(
+                    self._confirm(
+                        model, frame([100.0] * 3), side=LONG, order=ENTRY, rate=101.0, decay=decay
+                    )
+                )
+
+    # --- lookback depth ----------------------------------------------------------
+    def test_an_entry_lookback_deeper_than_the_frame_fails_closed(self):
+        model, _ = strategy([100.0])
+        self.assertFalse(
+            self._confirm(model, frame([100.0] * 3), side=LONG, order=ENTRY, rate=101.0, lookback=5)
+        )
+
+    def test_an_exit_lookback_deeper_than_the_frame_is_clamped_to_the_frame(self):
+        # Three rows allow a lookback of two at most, so the gate runs k=1 and k=2 rather
+        # than refusing, and the deeper request never reaches an unmeasurable candle.
+        model, state = strategy([100.0, 90.0, 90.0, 90.0, 90.0])
+        self.assertTrue(
+            self._confirm(
+                model, frame([100.0, 100.0, 100.0]), side=LONG, order=EXIT, rate=101.0, lookback=5
+            )
+        )
+        self.assertEqual([call["candle_idx"] for call in state["calls"]], [-1, -2, -3])
+
+    # --- the chain ----------------------------------------------------------------
+    def test_a_chain_breaking_at_the_first_history_candle_refuses_both_orders(self):
+        for order in (ENTRY, EXIT):
+            with self.subTest(order=order):
+                model, _ = strategy([100.0, 150.0, 150.0])
+                self.assertFalse(
+                    self._confirm(
+                        model,
+                        frame([100.0, 100.0, 100.0, 101.0]),
+                        side=LONG,
+                        order=order,
+                        rate=101.0,
+                        lookback=2,
+                    )
+                )
+
+    def test_a_chain_breaking_at_the_second_history_candle_refuses_both_orders(self):
+        for order in (ENTRY, EXIT):
+            with self.subTest(order=order):
+                model, _ = strategy([100.0, 90.0, 150.0, 150.0])
+                self.assertFalse(
+                    self._confirm(
+                        model,
+                        frame([100.0, 100.0, 100.0, 101.0]),
+                        side=LONG,
+                        order=order,
+                        rate=101.0,
+                        lookback=2,
+                    )
+                )
+
+    def test_a_chain_breaking_throughout_confirms(self):
+        model, _ = strategy([100.0, 90.0, 90.0, 90.0])
+        self.assertTrue(
+            self._confirm(
+                model,
+                frame([100.0, 100.0, 100.0, 101.0]),
+                side=LONG,
+                order=ENTRY,
+                rate=101.0,
+                lookback=2,
+            )
+        )
+
+    def test_a_short_chain_must_break_downwards_at_every_step(self):
+        # A short breaks downwards, so a LOW threshold is the hard case: close(-1) = 99.0
+        # cannot get below 90.0 and the chain breaks at the first history candle.
+        model, _ = strategy([100.0, 90.0, 90.0])
+        self.assertFalse(
+            self._confirm(
+                model,
+                frame([100.0, 100.0, 100.0, 99.0]),
+                side=SHORT,
+                order=ENTRY,
+                rate=99.0,
+                lookback=2,
+            )
+        )
+
+    # --- unmeasurable history: entry closes, exit degrades open -------------------
+    def test_a_non_finite_history_close_fails_an_entry_closed(self):
+        model, _ = strategy([100.0, 90.0, 90.0])
+        self.assertFalse(
+            self._confirm(
+                model,
+                frame([100.0, 100.0, 100.0, np.nan]),
+                side=LONG,
+                order=ENTRY,
+                rate=101.0,
+                lookback=2,
+            )
+        )
+
+    def test_a_non_finite_history_close_keeps_an_exit_degraded_open(self):
+        model, _ = strategy([100.0, 90.0, 90.0])
+        self.assertTrue(
+            self._confirm(
+                model,
+                frame([100.0, 100.0, 100.0, np.nan]),
+                side=LONG,
+                order=EXIT,
+                rate=101.0,
+                lookback=2,
+            )
+        )
+
+    def test_a_non_finite_history_threshold_keeps_an_exit_degraded_open(self):
+        for order, expected in ((EXIT, True), (ENTRY, False)):
+            with self.subTest(order=order):
+                model, _ = strategy([100.0, np.nan, 90.0])
+                self.assertIs(
+                    self._confirm(
+                        model,
+                        frame([100.0, 100.0, 100.0, 101.0]),
+                        side=LONG,
+                        order=order,
+                        rate=101.0,
+                        lookback=2,
+                    ),
+                    expected,
+                )
+
+    def test_degradation_never_manufactures_a_confirmation_of_a_broken_current_candle(self):
+        model, _ = strategy([100.0, np.nan])
+        self.assertFalse(
+            self._confirm(
+                model,
+                frame([100.0, 100.0, 100.0, 101.0]),
+                side=LONG,
+                order=EXIT,
+                rate=99.0,
+                lookback=2,
+            )
+        )
+
+    # --- the decay schedule -------------------------------------------------------
+    def test_the_multiplier_bounds_decay_geometrically_and_clamp_to_one(self):
+        model, state = strategy([100.0, 90.0, 90.0])
+        self._confirm(
+            model,
+            frame([100.0, 100.0, 100.0, 101.0]),
+            side=LONG,
+            order=ENTRY,
+            rate=101.0,
+            lookback=2,
+            decay=0.5,
+            lo=0.8,
+            hi=1.0,
+        )
+        self.assertEqual([call["candle_idx"] for call in state["calls"]], [-1, -2, -3])
+        self.assertAlmostEqual(state["calls"][0]["min"], 0.8)
+        self.assertAlmostEqual(state["calls"][0]["max"], 1.0)
+        self.assertAlmostEqual(state["calls"][1]["min"], 0.8 * 0.5)
+        self.assertAlmostEqual(state["calls"][2]["min"], 0.8 * 0.25)
+
+    def test_a_decayed_minimum_never_falls_below_zero(self):
+        model, state = strategy([100.0, 90.0, 90.0])
+        self._confirm(
+            model,
+            frame([100.0, 100.0, 100.0, 101.0]),
+            side=LONG,
+            order=ENTRY,
+            rate=101.0,
+            lookback=2,
+            decay=0.1,
+            lo=0.001,
+            hi=1.0,
+        )
+        for call in state["calls"][1:]:
+            self.assertGreaterEqual(call["min"], 0.0)
+            self.assertLessEqual(call["max"], 1.0)
+
+    def test_a_decayed_maximum_never_falls_below_the_decayed_minimum(self):
+        model, state = strategy([100.0, 90.0, 90.0])
+        self._confirm(
+            model,
+            frame([100.0, 100.0, 100.0, 101.0]),
+            side=LONG,
+            order=ENTRY,
+            rate=101.0,
+            lookback=2,
+            decay=0.5,
+            lo=1.0,
+            hi=1.0,
+        )
+        for call in state["calls"]:
+            self.assertGreaterEqual(call["max"], call["min"])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_strategy_sizing.py b/quickadapter/tests/test_strategy_sizing.py
new file mode 100644 (file)
index 0000000..4f09989
--- /dev/null
@@ -0,0 +1,310 @@
+"""Position sizing and throttle contracts; requires the Freqtrade QA image."""
+
+import datetime
+import math
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import QaTestCase
+from QuickAdapterV3 import QuickAdapterV3
+
+PAIR = "BTC/USDT"
+
+
+def strategy(*, duration=10, natr=2.0, fraction=0.5, candle_secs=300):
+    """A strategy whose sizing collaborators return fixed, stated values."""
+    model = object.__new__(QuickAdapterV3)
+    model.get_trade_duration_candles = lambda df, trade: duration
+    model.get_trade_natr = lambda df, trade, duration_candles: natr
+    model.get_label_natr_multiplier_fraction = lambda pair, value, df: value
+    model._candle_duration_secs = candle_secs
+    model.last_candle_start_secs = {}
+    return model
+
+
+def trade(*, open_rate=100.0, successful_exits=0):
+    return type(
+        "Trade",
+        (),
+        {
+            "pair": PAIR,
+            "open_rate": open_rate,
+            "nr_of_successful_exits": successful_exits,
+            "open_date_utc": datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+        },
+    )()
+
+
+def frame(rows: int = 4) -> pd.DataFrame:
+    return pd.DataFrame({"close": np.linspace(100.0, 104.0, rows)})
+
+
+class StoplossFactorTest(QaTestCase):
+    def test_the_factor_matches_its_formula(self):
+        for duration in (1, 5, 20, 100):
+            with self.subTest(duration=duration):
+                expected = 2.75 / (1.2675 + math.atan(0.25 * duration))
+                self.assertAlmostEqual(
+                    QuickAdapterV3.get_stoploss_factor(duration), expected, places=12
+                )
+
+    def test_the_factor_decays_with_duration(self):
+        factors = [QuickAdapterV3.get_stoploss_factor(d) for d in (1, 10, 50, 200)]
+        self.assertEqual(factors, sorted(factors, reverse=True))
+
+    def test_the_factor_stays_positive_at_extreme_durations(self):
+        for duration in (1, 10_000):
+            with self.subTest(duration=duration):
+                self.assertGreater(QuickAdapterV3.get_stoploss_factor(duration), 0.0)
+
+
+class TakeProfitFactorTest(QaTestCase):
+    def test_the_factor_matches_its_formula(self):
+        for duration in (1, 5, 20, 100):
+            with self.subTest(duration=duration):
+                self.assertAlmostEqual(
+                    QuickAdapterV3.get_take_profit_factor(duration),
+                    math.log10(9.75 + 0.25 * duration),
+                    places=12,
+                )
+
+    def test_the_factor_grows_with_duration(self):
+        factors = [QuickAdapterV3.get_take_profit_factor(d) for d in (1, 10, 50, 200)]
+        self.assertEqual(factors, sorted(factors))
+
+
+class TradeDurationTest(QaTestCase):
+    def test_only_a_positive_finite_duration_is_valid(self):
+        for duration, expected in ((1, True), (0.5, True), (0, False), (-1, False), (None, False)):
+            with self.subTest(duration=duration):
+                self.assertIs(QuickAdapterV3.is_trade_duration_valid(duration), expected)
+
+    def test_a_non_finite_duration_is_invalid(self):
+        # is_trade_duration_valid rejects NaN and non-positive values. It does not reject
+        # +inf, which is unreachable: get_trade_duration_candles returns an int or None.
+        for duration in (np.nan, -np.inf):
+            with self.subTest(duration=duration):
+                self.assertFalse(QuickAdapterV3.is_trade_duration_valid(duration))
+
+    def test_the_duration_is_the_elapsed_candles_since_entry(self):
+        # A bare instance, so the real get_trade_duration_candles runs.
+        model = object.__new__(QuickAdapterV3)
+        model.timeframe_minutes = 5
+        dates = pd.date_range("2026-01-01 00:00", periods=4, freq="5min", tz="UTC")
+        model.get_trade_entry_date = lambda trade: dates[0]
+        self.assertEqual(
+            model.get_trade_duration_candles(pd.DataFrame({"date": dates}), trade()), 3
+        )
+
+    def test_a_frame_without_dates_yields_no_duration(self):
+        model = object.__new__(QuickAdapterV3)
+        model.timeframe_minutes = 5
+        model.get_trade_entry_date = lambda trade: datetime.datetime(
+            2026, 1, 1, tzinfo=datetime.UTC
+        )
+        self.assertIsNone(model.get_trade_duration_candles(pd.DataFrame({"close": [1.0]}), trade()))
+
+
+class DistanceTest(QaTestCase):
+    def _stoploss(self, model, **kwargs):
+        return model.get_stoploss_distance(
+            frame(), trade(**kwargs.pop("trade", {})), 100.0, **kwargs
+        )
+
+    def _take_profit(self, model, **kwargs):
+        return model.get_take_profit_distance(frame(), trade(**kwargs.pop("trade", {})), **kwargs)
+
+    def test_the_stoploss_distance_matches_its_formula(self):
+        model = strategy(duration=10, natr=2.0)
+        expected = (
+            100.0 * (2.0 / 100.0) * 0.5 * QuickAdapterV3.get_stoploss_factor(10 + round(0**1.5))
+        )
+        self.assertAlmostEqual(
+            model.get_stoploss_distance(frame(), trade(), 100.0, 0.5), expected, places=10
+        )
+
+    def test_the_take_profit_distance_uses_the_open_rate_not_the_current_rate(self):
+        model = strategy(duration=10, natr=2.0)
+        expected = 100.0 * (2.0 / 100.0) * 0.5 * QuickAdapterV3.get_take_profit_factor(10)
+        self.assertAlmostEqual(
+            model.get_take_profit_distance(frame(), trade(open_rate=100.0), 0.5),
+            expected,
+            places=10,
+        )
+        # A different open rate moves the target proportionally, which is what fixing the
+        # target to the entry volatility assumption means.
+        doubled = model.get_take_profit_distance(frame(), trade(open_rate=200.0), 0.5)
+        self.assertAlmostEqual(doubled, 2 * expected, places=10)
+
+    def test_a_successful_exit_narrows_the_stoploss(self):
+        model = strategy(duration=10, natr=2.0)
+        without = model.get_stoploss_distance(frame(), trade(), 100.0, 0.5)
+        with_exit = model.get_stoploss_distance(frame(), trade(successful_exits=9), 100.0, 0.5)
+        self.assertLess(with_exit, without)
+
+    def test_both_distances_refuse_a_multiplier_fraction_outside_the_unit_interval(self):
+        for fraction in (-0.1, 1.1):
+            with self.subTest(fraction=fraction):
+                model = strategy()
+                with self.assertRaisesRegex(ValueError, "must be in range"):
+                    model.get_stoploss_distance(frame(), trade(), 100.0, fraction)
+                with self.assertRaisesRegex(ValueError, "must be in range"):
+                    model.get_take_profit_distance(frame(), trade(), fraction)
+
+    def test_both_distances_accept_the_closed_unit_interval(self):
+        for fraction, is_zero in ((0.0, True), (1.0, False)):
+            with self.subTest(fraction=fraction):
+                model = strategy()
+                distance = model.get_stoploss_distance(frame(), trade(), 100.0, fraction)
+                self.assertEqual(distance == 0.0, is_zero)
+                self.assertGreaterEqual(distance, 0.0)
+
+    def test_both_distances_refuse_an_invalid_duration(self):
+        model = strategy(duration=0)
+        self.assertIsNone(model.get_stoploss_distance(frame(), trade(), 100.0, 0.5))
+        self.assertIsNone(model.get_take_profit_distance(frame(), trade(), 0.5))
+
+    def test_both_distances_refuse_a_missing_or_negative_natr(self):
+        for natr in (np.nan, -1.0):
+            with self.subTest(natr=natr):
+                model = strategy(natr=natr)
+                self.assertIsNone(model.get_stoploss_distance(frame(), trade(), 100.0, 0.5))
+                self.assertIsNone(model.get_take_profit_distance(frame(), trade(), 0.5))
+
+    def test_a_zero_natr_collapses_both_distances(self):
+        model = strategy(natr=0.0)
+        self.assertEqual(model.get_stoploss_distance(frame(), trade(), 100.0, 0.5), 0.0)
+        self.assertEqual(model.get_take_profit_distance(frame(), trade(), 0.5), 0.0)
+
+
+class ThrottleCallbackTest(QaTestCase):
+    def test_a_non_callable_is_refused(self):
+        model = strategy()
+        with self.assertRaisesRegex(ValueError, "must be callable"):
+            model.throttle_callback(PAIR, datetime.datetime.now(datetime.UTC), "not callable")
+
+    def test_the_callback_fires_once_per_candle(self):
+        model = strategy(candle_secs=300)
+        fired = []
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+        for minute in (0, 1, 4):
+            model.throttle_callback(
+                PAIR, start + datetime.timedelta(minutes=minute), lambda: fired.append(1)
+            )
+        self.assertEqual(len(fired), 1)
+
+    def test_the_callback_fires_again_on_the_next_candle(self):
+        model = strategy(candle_secs=300)
+        fired = []
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+        for minute in (0, 4, 5):
+            model.throttle_callback(
+                PAIR, start + datetime.timedelta(minutes=minute), lambda: fired.append(1)
+            )
+        self.assertEqual(len(fired), 2)
+
+    def test_a_failing_callback_does_not_stop_the_next_candle(self):
+        model = strategy(candle_secs=300)
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+
+        def boom():
+            raise RuntimeError("boom")
+
+        model.throttle_callback(PAIR, start, boom)
+        with self.assertLogs("QuickAdapterV3", level="ERROR"):
+            model.throttle_callback(PAIR, start + datetime.timedelta(minutes=5), boom)
+        self.assertEqual(len(model.last_candle_start_secs), 1)
+
+    def test_the_throttle_key_is_the_bytecode_not_the_callable_identity(self):
+        # get_callable_sha256 hashes __code__.co_code, so two functions with the same body
+        # are one callback as far as the throttle is concerned, whatever their names.
+        model = strategy(candle_secs=300)
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+        fired = []
+
+        def first():
+            fired.append("first")
+
+        def second():
+            fired.append("second")
+
+        self.assertIsNot(first, second)
+        model.throttle_callback(PAIR, start, first)
+        model.throttle_callback(PAIR, start, second)
+        self.assertEqual(fired, ["first"])
+        self.assertEqual(len(model.last_candle_start_secs), 1)
+
+    def test_callables_with_different_bodies_get_distinct_keys(self):
+        model = strategy(candle_secs=300)
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+        fired = []
+
+        def first():
+            fired.append(1)
+            return None
+
+        def second():
+            fired.append(2)
+            return None
+
+        model.throttle_callback(PAIR, start, first)
+        model.throttle_callback(PAIR, start, second)
+        self.assertEqual(fired, [1, 2])
+        self.assertEqual(len(model.last_candle_start_secs), 2)
+
+    def test_stale_keys_are_evicted_beyond_ten_candles(self):
+        # Two callbacks are required: a single call can only ever produce one key, and the
+        # eviction loop compares the triggering key — whose timestamp it just wrote —
+        # against the rest of the dict, so with one key there is nothing to evict.
+        #
+        # They must differ by a SMALL-INT constant, not a string. get_callable_sha256
+        # hashes co_code, which carries opcodes and opargs only and never the constant
+        # VALUES: in CPython 3.14 a string constant is loaded through a constant-index
+        # oparg, so `return "stale"` and `return "fresh"` compile to byte-identical
+        # bytecode and share a key, while 3.14 inlines small ints into the oparg and
+        # `append(1)` differs from `append(2)`.
+        model = strategy(candle_secs=300)
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+        fired = []
+
+        def stale():
+            fired.append(1)
+
+        def fresh():
+            fired.append(2)
+
+        model.throttle_callback(PAIR, start, stale)
+        stale_key = next(iter(model.last_candle_start_secs))
+        self.assertEqual(fired, [1])
+
+        model.throttle_callback(PAIR, start, fresh)
+        self.assertEqual(fired, [1, 2])
+        self.assertEqual(len(model.last_candle_start_secs), 2)
+
+        # Eleven candles on: the budget is ten * candle_duration_secs = 50 minutes at
+        # 300-second candles, and 55 clears it. The key just written is fresh.
+        later = start + datetime.timedelta(minutes=11 * 5)
+        model.throttle_callback(PAIR, later, fresh)
+        self.assertEqual(fired, [1, 2, 2])
+        self.assertEqual(len(model.last_candle_start_secs), 1)
+        self.assertNotIn(
+            stale_key,
+            model.last_candle_start_secs,
+            "the stale callback's key survived eviction",
+        )
+
+    def test_pairs_are_throttled_independently(self):
+        model = strategy(candle_secs=300)
+        start = datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC)
+
+        def callback():
+            return None
+
+        model.throttle_callback("BTC/USDT", start, callback)
+        model.throttle_callback("ETH/USDT", start, callback)
+        self.assertEqual(len(model.last_candle_start_secs), 2)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_strategy_targets.py b/quickadapter/tests/test_strategy_targets.py
new file mode 100644 (file)
index 0000000..2777987
--- /dev/null
@@ -0,0 +1,628 @@
+"""Label target, indicator and entry-signal contracts; requires the Freqtrade QA image."""
+
+import logging
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import PAIR, QaTestCase, model_config, temporary_directory
+from QuickAdapterV3 import QuickAdapterV3
+from Utils import EXTREMA_COLUMN, generate_label_data, smooth
+
+LONG = QuickAdapterV3._TRADE_LONG
+SHORT = QuickAdapterV3._TRADE_SHORT
+PREDICT_COLUMN = "do_predict"
+CATCH_COLUMN = "DI_catch"
+MINIMA_COLUMN = "minima_threshold"
+MAXIMA_COLUMN = "maxima_threshold"
+LABEL_HORIZON_COLUMN = "s-extrema_known_at_lookahead"
+LABEL_WEIGHT_COLUMN = "s-extrema_weight"
+WEIGHT_COLUMNS = (
+    LABEL_WEIGHT_COLUMN,
+    "extrema_weight",
+    "extrema_weight_smoothed",
+    "s-extrema_weight_known_at_lookahead",
+)
+logger = logging.getLogger(__name__)
+
+
+def label_strategy(feature_parameters=None, label_params=None):
+    """A strategy carrying only the label state the accessors read."""
+    model = object.__new__(QuickAdapterV3)
+    model.freqai_info = {"feature_parameters": dict(feature_parameters or {})}
+    model._label_params = dict(label_params or {PAIR: {}})
+    model._candle_deviation_cache = {}
+    model._candle_threshold_cache = {}
+    model._cached_df_signature = {}
+    return model
+
+
+def runtime(tmp, **overrides):
+    """A strategy from a real config, with the FreqAI frame seam stubbed."""
+    config = model_config(tmp, **overrides)
+    model = QuickAdapterV3(config)
+    model.freqai_info = config["freqai"]
+    model.freqai = type("FreqaiKitchen", (), {"start": staticmethod(lambda df, md, s: df)})()
+    return model
+
+
+def candles(rows: int = 6, **columns) -> pd.DataFrame:
+    """A strictly rising OHLCV frame, extended with the named columns."""
+    index = np.arange(rows)
+    frame = pd.DataFrame(
+        {
+            "date": pd.date_range("2026-01-01", periods=rows, freq="5min", tz="UTC"),
+            "high": 101.0 + index,
+            "low": 99.0 - index,
+            "close": 100.0 + index * 0.5,
+        }
+    )
+    for name, values in columns.items():
+        frame[name] = values
+    return frame
+
+
+def zigzag(rows: int = 200, amplitude: float = 6.0, period: int = 17) -> pd.DataFrame:
+    """A deterministic oscillating OHLCV frame that produces extrema labels."""
+    close = 100 + amplitude * np.sin(2 * np.pi * np.arange(rows) / period)
+    return pd.DataFrame(
+        {
+            "date": pd.date_range("2026-01-01", periods=rows, freq="5min", tz="UTC"),
+            "open": close,
+            "high": close + 0.3,
+            "low": close - 0.3,
+            "close": close,
+            "volume": np.full(rows, 10.0),
+        }
+    )
+
+
+def entry_frame(predicts, catches, extrema, minima=0.0, maxima=0.0) -> pd.DataFrame:
+    rows = len(predicts)
+    return pd.DataFrame(
+        {
+            PREDICT_COLUMN: predicts,
+            CATCH_COLUMN: catches,
+            EXTREMA_COLUMN: extrema,
+            MINIMA_COLUMN: np.full(rows, minima),
+            MAXIMA_COLUMN: np.full(rows, maxima),
+        }
+    )
+
+
+def fired(result: pd.DataFrame, column: str) -> list[int]:
+    """The row positions where an entry column carries 1; a void column fires nowhere."""
+    return [i for i, value in enumerate(result[column].astype(object)) if value == 1]
+
+
+def tags(result: pd.DataFrame) -> dict[int, str]:
+    return {
+        row: result["enter_tag"].astype(object).iloc[row]
+        for column in ("enter_long", "enter_short")
+        for row in fired(result, column)
+    }
+
+
+class StrategyTargetsTest(QaTestCase):
+    def test_a_long_entry_needs_a_prediction_a_caught_divergence_and_a_below_minimum(self):
+        model = object.__new__(QuickAdapterV3)
+        for predicts in (1, 0):
+            for catches in (1, 0):
+                for below in (True, False):
+                    with self.subTest(predict=predicts, catch=catches, below=below):
+                        frame = entry_frame(
+                            [predicts], [catches], [-1.0 if below else 1.0], maxima=2.0
+                        )
+                        result = model.populate_entry_trend(frame, {"pair": PAIR})
+                        expected = [0] if predicts == catches == 1 and below else []
+                        self.assertEqual(fired(result, "enter_long"), expected)
+                        self.assertEqual(fired(result, "enter_short"), [])
+
+    def test_a_short_entry_needs_a_prediction_a_caught_divergence_and_an_above_maximum(self):
+        model = object.__new__(QuickAdapterV3)
+        for predicts in (1, 0):
+            for catches in (1, 0):
+                for above in (True, False):
+                    with self.subTest(predict=predicts, catch=catches, above=above):
+                        frame = entry_frame(
+                            [predicts], [catches], [1.0 if above else -1.0], minima=-2.0
+                        )
+                        result = model.populate_entry_trend(frame, {"pair": PAIR})
+                        expected = [0] if predicts == catches == 1 and above else []
+                        self.assertEqual(fired(result, "enter_short"), expected)
+                        self.assertEqual(fired(result, "enter_long"), [])
+
+    def test_the_extrema_comparison_is_strict_at_the_threshold(self):
+        # The truth tables above only ever use strictly separated values, so they would
+        # pass against a non-strict `<` too. Equality is the boundary: an extremum
+        # exactly AT its threshold must NOT enter, in either direction.
+        model = object.__new__(QuickAdapterV3)
+        at_long = entry_frame([1], [1], [0.0], minima=0.0, maxima=5.0)
+        self.assertEqual(
+            fired(model.populate_entry_trend(at_long, {"pair": PAIR}), "enter_long"), []
+        )
+
+        at_short = entry_frame([1], [1], [0.0], minima=-5.0, maxima=0.0)
+        self.assertEqual(
+            fired(model.populate_entry_trend(at_short, {"pair": PAIR}), "enter_short"), []
+        )
+
+        # One ULP past the threshold in each direction does enter, which shows the
+        # equality cases above are the comparison and not a broken fixture.
+        just_under = entry_frame([1], [1], [np.nextafter(0.0, -np.inf)], minima=0.0, maxima=5.0)
+        self.assertEqual(
+            fired(model.populate_entry_trend(just_under, {"pair": PAIR}), "enter_long"), [0]
+        )
+        just_over = entry_frame([1], [1], [np.nextafter(0.0, np.inf)], minima=-5.0, maxima=0.0)
+        self.assertEqual(
+            fired(model.populate_entry_trend(just_over, {"pair": PAIR}), "enter_short"), [0]
+        )
+
+    def test_a_prediction_other_than_one_is_not_an_entry(self):
+        model = object.__new__(QuickAdapterV3)
+        frame = entry_frame([-1, 2, 1.0], [1, 1, 1], [-1.0, -1.0, -1.0], maxima=2.0)
+        result = model.populate_entry_trend(frame, {"pair": PAIR})
+        self.assertEqual(fired(result, "enter_long"), [2])
+
+    def test_ordered_thresholds_make_the_two_sides_mutually_exclusive(self):
+        model = object.__new__(QuickAdapterV3)
+        frame = entry_frame([1] * 3, [1] * 3, [-3.0, 0.0, 3.0], minima=-2.0, maxima=2.0)
+        result = model.populate_entry_trend(frame, {"pair": PAIR})
+        longs, shorts = fired(result, "enter_long"), fired(result, "enter_short")
+        self.assertEqual(longs, [0])
+        self.assertEqual(shorts, [2])
+        self.assertFalse(set(longs) & set(shorts))
+
+    def test_the_enter_tag_names_the_side_that_fired(self):
+        model = object.__new__(QuickAdapterV3)
+        frame = entry_frame([1, 1], [1, 1], [-1.0, 1.0])
+        result = model.populate_entry_trend(frame, {"pair": PAIR})
+        self.assertEqual(tags(result), {0: LONG, 1: SHORT})
+        self.assertEqual([tags(result)[row] for row in fired(result, "enter_long")], [LONG])
+
+    def test_inverted_thresholds_fire_both_sides_and_leave_the_short_tag(self):
+        model = object.__new__(QuickAdapterV3)
+        frame = entry_frame([1], [1], [0.0], minima=1.0, maxima=-1.0)
+        result = model.populate_entry_trend(frame, {"pair": PAIR})
+        self.assertEqual(fired(result, "enter_long"), [0])
+        self.assertEqual(fired(result, "enter_short"), [0])
+        self.assertEqual(tags(result), {0: SHORT})
+
+    def test_a_missing_condition_column_denies_both_sides(self):
+        columns = {
+            PREDICT_COLUMN: [1],
+            CATCH_COLUMN: [1],
+            EXTREMA_COLUMN: [-1.0],
+            MINIMA_COLUMN: [0.0],
+            MAXIMA_COLUMN: [0.0],
+        }
+        for dropped in (PREDICT_COLUMN, CATCH_COLUMN, EXTREMA_COLUMN):
+            with self.subTest(dropped=dropped):
+                model = object.__new__(QuickAdapterV3)
+                frame = pd.DataFrame(
+                    {name: values for name, values in columns.items() if name != dropped}
+                )
+                result = model.populate_entry_trend(frame, {"pair": PAIR})
+                self.assertEqual(fired(result, "enter_long"), [])
+                self.assertEqual(fired(result, "enter_short"), [])
+
+    def test_a_missing_threshold_column_denies_only_the_side_that_reads_it(self):
+        columns = {
+            PREDICT_COLUMN: [1, 1],
+            CATCH_COLUMN: [1, 1],
+            EXTREMA_COLUMN: [-1.0, 1.0],
+            MINIMA_COLUMN: [0.0, 0.0],
+            MAXIMA_COLUMN: [0.0, 0.0],
+        }
+        # A dropped threshold leaves dataframe.get at None, whose comparison is False on
+        # every row, so the side reading it is denied and the opposite side stands.
+        survivors = {
+            "minima_threshold": ("enter_short", [1]),
+            "maxima_threshold": ("enter_long", [0]),
+        }
+        for dropped, (survivor, survivor_rows) in survivors.items():
+            with self.subTest(dropped=dropped):
+                model = object.__new__(QuickAdapterV3)
+                frame = pd.DataFrame(
+                    {name: values for name, values in columns.items() if name != dropped}
+                )
+                result = model.populate_entry_trend(frame, {"pair": PAIR})
+                denied = "enter_short" if survivor == "enter_long" else "enter_long"
+                self.assertEqual(fired(result, survivor), survivor_rows)
+                self.assertEqual(fired(result, denied), [])
+
+    def test_a_nan_threshold_never_fires_its_side(self):
+        model = object.__new__(QuickAdapterV3)
+        frame = entry_frame([1, 1], [1, 1], [-1.0, 1.0], minima=np.nan)
+        result = model.populate_entry_trend(frame, {"pair": PAIR})
+        self.assertEqual(fired(result, "enter_long"), [])
+        self.assertEqual(fired(result, "enter_short"), [1])
+
+    def test_a_catch_is_one_without_both_freqai_divergence_columns(self):
+        for columns in ({}, {"DI_values": [4.0] * 6}, {"DI_cutoff": [2.0] * 6}):
+            with self.subTest(supplied=sorted(columns)), temporary_directory() as temp:
+                model = runtime(temp)
+                model.bot_start()
+                result = model.populate_indicators(candles(**columns), {"pair": PAIR})
+                self.assertEqual(result[CATCH_COLUMN].tolist(), [1] * 6)
+
+    def test_a_divergence_is_caught_unless_it_exceeds_the_cutoff(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            result = model.populate_indicators(
+                candles(5, DI_values=[1.0, 2.0, 2.0001, 9.0, np.nan], DI_cutoff=[2.0] * 5),
+                {"pair": PAIR},
+            )
+            self.assertEqual(result[CATCH_COLUMN].tolist(), [1, 1, 0, 0, 1])
+
+    def test_the_extrema_thresholds_are_copied_verbatim(self):
+        minima, maxima = [-2.0, -1.5, 0.0], [2.0, 1.5, 0.0]
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            result = model.populate_indicators(
+                candles(
+                    3,
+                    **{
+                        f"{EXTREMA_COLUMN}_minima_threshold": minima,
+                        f"{EXTREMA_COLUMN}_maxima_threshold": maxima,
+                    },
+                ),
+                {"pair": PAIR},
+            )
+            self.assertEqual(result[MINIMA_COLUMN].tolist(), minima)
+            self.assertEqual(result[MAXIMA_COLUMN].tolist(), maxima)
+
+    def test_an_absent_extrema_threshold_becomes_nan(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            absent = model.populate_indicators(candles(), {"pair": PAIR})
+            self.assertTrue(absent[MINIMA_COLUMN].isna().all())
+            self.assertTrue(absent[MAXIMA_COLUMN].isna().all())
+            only_minima = model.populate_indicators(
+                candles(**{f"{EXTREMA_COLUMN}_minima_threshold": [-1.0] * 6}), {"pair": PAIR}
+            )
+            self.assertEqual(only_minima[MINIMA_COLUMN].tolist(), [-1.0] * 6)
+            self.assertTrue(only_minima[MAXIMA_COLUMN].isna().all())
+            only_maxima = model.populate_indicators(
+                candles(**{f"{EXTREMA_COLUMN}_maxima_threshold": [1.0] * 6}), {"pair": PAIR}
+            )
+            self.assertEqual(only_maxima[MAXIMA_COLUMN].tolist(), [1.0] * 6)
+            self.assertTrue(only_maxima[MINIMA_COLUMN].isna().all())
+
+    def test_a_per_candle_label_period_scatters_natR_over_its_own_rows(self):
+        periods = [2, 2, 2, 3, 3, 3]
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            scattered = model.populate_indicators(
+                candles(label_period_candles=periods), {"pair": PAIR}
+            )
+            for period in sorted(set(periods)):
+                with self.subTest(period=period):
+                    rows = [i for i, value in enumerate(periods) if value == period]
+                    alone = model.populate_indicators(
+                        candles(label_period_candles=[period] * 6), {"pair": PAIR}
+                    )
+                    np.testing.assert_allclose(
+                        scattered["natr_label_period_candles"].iloc[rows].to_numpy(),
+                        alone["natr_label_period_candles"].iloc[rows].to_numpy(),
+                        rtol=1e-9,
+                        atol=1e-12,
+                    )
+
+    def test_the_last_candle_of_a_per_candle_column_reaches_the_pair_state(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            model.populate_indicators(
+                candles(label_period_candles=[2, 2, 2, 2, 2, 7], label_natr_multiplier=[5.0] * 6),
+                {"pair": PAIR},
+            )
+            self.assertEqual(model._label_params[PAIR]["label_period_candles"], 7)
+            self.assertEqual(model.get_label_period_candles(PAIR), 7)
+            self.assertEqual(model._label_params[PAIR]["label_natr_multiplier"], 5.0)
+            self.assertEqual(model.get_label_natr_multiplier(PAIR), 5.0)
+
+    def test_bot_start_refuses_a_missing_or_empty_pair_whitelist(self):
+        for pair_whitelist in (None, []):
+            with (
+                self.subTest(pair_whitelist=pair_whitelist),
+                temporary_directory() as temp,
+            ):
+                config = model_config(temp)
+                if pair_whitelist is None:
+                    config["exchange"].pop("pair_whitelist")
+                else:
+                    config["exchange"]["pair_whitelist"] = pair_whitelist
+                model = QuickAdapterV3(config)
+                model.freqai_info = config["freqai"]
+                with self.assertRaisesRegex(ValueError, "pair_whitelist"):
+                    model.bot_start()
+
+    def test_bot_start_refuses_a_missing_blank_or_non_string_identifier(self):
+        for identifier in (None, "", "   ", 7):
+            with self.subTest(identifier=repr(identifier)), temporary_directory() as temp:
+                config = model_config(temp)
+                if identifier is None:
+                    config["freqai"].pop("identifier")
+                else:
+                    config["freqai"]["identifier"] = identifier
+                model = QuickAdapterV3(config)
+                model.freqai_info = config["freqai"]
+                with self.assertRaisesRegex(ValueError, "identifier"):
+                    model.bot_start()
+
+    def test_bot_start_refuses_wrap_smoothing_under_causal_mode(self):
+        with temporary_directory() as temp:
+            config = model_config(
+                temp,
+                freqai={
+                    "feature_parameters": {"causal_mode": True},
+                    "label_smoothing": {"default": {"method": "savgol", "mode": "wrap"}},
+                },
+            )
+            model = QuickAdapterV3(config)
+            model.freqai_info = config["freqai"]
+            with self.assertRaisesRegex(ValueError, "wrap.*incompatible"):
+                model.bot_start()
+
+    def test_bot_start_accepts_a_compatible_smoothing_configuration(self):
+        for causal_mode, method, mode in (
+            (True, "savgol", "mirror"),
+            (True, "savgol", "interp"),
+            (False, "savgol", "wrap"),
+        ):
+            with (
+                self.subTest(causal_mode=causal_mode, method=method, mode=mode),
+                temporary_directory() as temp,
+            ):
+                config = model_config(
+                    temp,
+                    freqai={
+                        "feature_parameters": {"causal_mode": causal_mode},
+                        "label_smoothing": {"default": {"method": method, "mode": mode}},
+                    },
+                )
+                model = QuickAdapterV3(config)
+                model.freqai_info = config["freqai"]
+                model.bot_start()
+                self.assertEqual(model.pairs, [PAIR])
+
+    def test_bot_start_seeds_the_pair_state_from_the_configuration(self):
+        with temporary_directory() as temp:
+            config = model_config(temp, freqai={"feature_parameters": {"label_period_candles": 7}})
+            model = QuickAdapterV3(config)
+            model.freqai_info = config["freqai"]
+            model.bot_start()
+            self.assertEqual(model.pairs, [PAIR])
+            self.assertEqual(
+                model.models_full_path, temp / "models" / config["freqai"]["identifier"]
+            )
+            self.assertEqual(model._label_params[PAIR]["label_period_candles"], 7)
+            self.assertIsInstance(model._label_params[PAIR]["label_natr_multiplier"], float)
+            self.assertEqual(model._candle_duration_secs, 300)
+            self.assertEqual(model._max_take_profit_history_size, 144)
+            self.assertEqual(model._candle_deviation_cache, {})
+            self.assertEqual(model._candle_threshold_cache, {})
+            self.assertEqual(model._cached_df_signature, {})
+
+    def test_a_non_finite_or_non_positive_label_period_is_refused(self):
+        for value in (np.nan, np.inf, -np.inf, 0, -3, "4", None, True):
+            with self.subTest(value=repr(value)):
+                model = label_strategy(label_params={PAIR: {"label_period_candles": 5}})
+                model.set_label_period_candles(PAIR, value)
+                self.assertEqual(model._label_params[PAIR]["label_period_candles"], 5)
+                self.assertEqual(model.get_label_period_candles(PAIR), 5)
+
+    def test_a_non_finite_or_non_positive_nat_multiplier_is_refused(self):
+        for value in (np.nan, np.inf, 0.0, -2.0, "1.5", None, True):
+            with self.subTest(value=repr(value)):
+                model = label_strategy(label_params={PAIR: {"label_natr_multiplier": 10.5}})
+                model.set_label_natr_multiplier(PAIR, value)
+                self.assertEqual(model._label_params[PAIR]["label_natr_multiplier"], 10.5)
+                self.assertEqual(model.get_label_natr_multiplier(PAIR), 10.5)
+
+    def test_setting_a_label_parameter_drops_the_pair_caches_and_changes_the_read(self):
+        cases = (
+            ("set_label_period_candles", "label_period_candles", 3, "get_label_period_candles"),
+            (
+                "set_label_natr_multiplier",
+                "label_natr_multiplier",
+                4.5,
+                "get_label_natr_multiplier",
+            ),
+        )
+        for setter, key, value, getter in cases:
+            with self.subTest(parameter=key):
+                initial = 5 if key == "label_period_candles" else 10.5
+                model = label_strategy(label_params={PAIR: {key: initial}})
+                model._candle_deviation_cache = {
+                    (PAIR, (1, None), 0.0, 1.0, -1, "direct", 1.5): 0.25
+                }
+                model._candle_threshold_cache = {(PAIR, (1, None), LONG, -1, 0.0, 1.0): 110.0}
+                model._cached_df_signature = {PAIR: (1, None)}
+                getattr(model, setter)(PAIR, value)
+                self.assertEqual(model._candle_deviation_cache, {})
+                self.assertEqual(model._candle_threshold_cache, {})
+                self.assertEqual(model._cached_df_signature, {})
+                self.assertEqual(getattr(model, getter)(PAIR), value)
+
+    def test_setting_an_unchanged_label_parameter_keeps_the_pair_caches(self):
+        model = label_strategy(label_params={PAIR: {"label_period_candles": 5}})
+        model._cached_df_signature = {PAIR: (1, None)}
+        model.set_label_period_candles(PAIR, 5)
+        self.assertEqual(model._cached_df_signature, {PAIR: (1, None)})
+
+    def test_a_candle_parameter_prefers_its_column_over_the_pair_value(self):
+        model = label_strategy(
+            label_params={PAIR: {"label_period_candles": 5, "label_natr_multiplier": 10.5}}
+        )
+        frame = pd.DataFrame(
+            {
+                "label_period_candles": [2, 4, np.nan, 0],
+                "label_natr_multiplier": [1.5, np.nan, 3.0, -1.0],
+            }
+        )
+        self.assertEqual(
+            [model.get_label_period_candles(PAIR, frame, i) for i in range(len(frame))],
+            [2, 4, 5, 5],
+        )
+        self.assertEqual(
+            [model.get_label_natr_multiplier(PAIR, frame, i) for i in range(len(frame))],
+            [1.5, 10.5, 3.0, 10.5],
+        )
+        self.assertEqual(model.get_label_period_candles(PAIR, frame), 5)
+        self.assertEqual(model.get_label_natr_multiplier(PAIR, frame), 10.5)
+
+    def test_an_unknown_pair_falls_back_to_the_canonical_defaults(self):
+        model = label_strategy()
+        self.assertEqual(model.get_label_period_candles(PAIR), 18)
+        self.assertEqual(model.get_label_natr_multiplier(PAIR), 10.5)
+        self.assertEqual(model.get_label_horizon_candles(PAIR), 18)
+
+    def test_a_configured_horizon_overrides_the_label_period(self):
+        model = label_strategy(
+            feature_parameters={"label_horizon_candles": 30},
+            label_params={PAIR: {"label_period_candles": 4}},
+        )
+        self.assertEqual(model.get_label_horizon_candles(PAIR), 30)
+
+    def test_the_label_params_carry_the_extrema_triple_only(self):
+        model = label_strategy(
+            feature_parameters={"label_horizon_candles": 12},
+            label_params={PAIR: {"label_period_candles": 4, "label_natr_multiplier": 2.0}},
+        )
+        self.assertEqual(
+            model.get_label_params(PAIR, EXTREMA_COLUMN),
+            {"natr_period": 4, "natr_multiplier": 2.0, "label_horizon_candles": 12},
+        )
+        self.assertEqual(model.get_label_params(PAIR, "other-label"), {})
+
+    def test_the_nat_multiplier_fraction_validates_its_own_bounds(self):
+        model = label_strategy(label_params={PAIR: {"label_natr_multiplier": 10.5}})
+        for fraction, expected in ((0.0, 0.0), (0.5, 5.25), (1.0, 10.5)):
+            with self.subTest(fraction=fraction):
+                self.assertAlmostEqual(
+                    model.get_label_natr_multiplier_fraction(PAIR, fraction),
+                    expected,
+                    places=12,
+                )
+        for fraction in (0, 1, -0.1, 1.1, "0.5", None):
+            with (
+                self.subTest(fraction=repr(fraction)),
+                self.assertRaisesRegex(ValueError, r"must be a float in range \[0, 1\]"),
+            ):
+                model.get_label_natr_multiplier_fraction(PAIR, fraction)
+
+    def test_a_label_period_truncates_to_an_integer_and_a_multiplier_widens_to_a_float(self):
+        model = label_strategy()
+        model.set_label_period_candles(PAIR, 3.9)
+        model.set_label_natr_multiplier(PAIR, 3)
+        self.assertEqual(model._label_params[PAIR]["label_period_candles"], 3)
+        self.assertIsInstance(model._label_params[PAIR]["label_period_candles"], int)
+        self.assertEqual(model._label_params[PAIR]["label_natr_multiplier"], 3.0)
+        self.assertIsInstance(model._label_params[PAIR]["label_natr_multiplier"], float)
+
+    def test_the_label_column_is_the_smoothed_series_and_the_direction_is_not(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            frame = zigzag()
+            result = model.set_freqai_targets(frame.copy(), {"pair": PAIR})
+            raw = generate_label_data(
+                frame.copy(),
+                EXTREMA_COLUMN,
+                model.get_label_params(PAIR, EXTREMA_COLUMN),
+                logger,
+            )
+            self.assertTrue((result["extrema_direction"].to_numpy() == raw.series.to_numpy()).all())
+            self.assertEqual(
+                sorted(result["extrema_direction"].unique().tolist()), [-1.0, 0.0, 1.0]
+            )
+            pd.testing.assert_series_equal(
+                result[EXTREMA_COLUMN],
+                smooth(raw.series, **model.label_smoothing["default"]),
+                check_names=False,
+                check_exact=False,
+                rtol=1e-9,
+                atol=1e-9,
+            )
+            pd.testing.assert_series_equal(
+                result["extrema_direction_smoothed"],
+                result[EXTREMA_COLUMN],
+                check_names=False,
+            )
+            self.assertNotIn(EXTREMA_COLUMN, frame.columns)
+            self.assertTrue(result[EXTREMA_COLUMN].notna().any())
+
+    def test_every_labelled_candle_carries_at_least_one_candle_of_lookahead(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            result = model.set_freqai_targets(zigzag(), {"pair": PAIR})
+            horizon = result[LABEL_HORIZON_COLUMN].to_numpy()
+            labelled = result[EXTREMA_COLUMN].to_numpy() != 0.0
+            self.assertTrue(labelled.any())
+            self.assertTrue(np.isfinite(horizon).all())
+            self.assertTrue((horizon == np.round(horizon)).all())
+            self.assertTrue((horizon[labelled] >= 1).all())
+
+    def test_a_flat_price_series_produces_no_labels_and_no_label_weights(self):
+        flat = pd.DataFrame(
+            {
+                "date": pd.date_range("2026-01-01", periods=60, freq="5min", tz="UTC"),
+                "open": np.full(60, 100.0),
+                "high": np.full(60, 100.1),
+                "low": np.full(60, 99.9),
+                "close": np.full(60, 100.0),
+                "volume": np.full(60, 10.0),
+            }
+        )
+        with temporary_directory() as temp:
+            model = runtime(temp, freqai={"label_weighting": {"default": {"strategy": "uniform"}}})
+            model.bot_start()
+            result = model.set_freqai_targets(flat, {"pair": PAIR})
+            self.assertEqual(result[EXTREMA_COLUMN].fillna(0.0).ne(0.0).sum(), 0)
+            for column in WEIGHT_COLUMNS:
+                with self.subTest(column=column):
+                    self.assertNotIn(column, result.columns)
+
+    def test_a_label_weight_is_positive_on_every_labelled_candle(self):
+        with temporary_directory() as temp:
+            model = runtime(temp, freqai={"label_weighting": {"default": {"strategy": "uniform"}}})
+            model.bot_start()
+            result = model.set_freqai_targets(zigzag(), {"pair": PAIR})
+            labelled = result[EXTREMA_COLUMN].to_numpy() != 0.0
+            weights = result[LABEL_WEIGHT_COLUMN]
+            smoothed = result["extrema_weight_smoothed"]
+            self.assertTrue((weights[labelled] > 0.0).all())
+            self.assertTrue((weights >= 0.0).all())
+            self.assertTrue(np.isfinite(smoothed.to_numpy()).all())
+            self.assertTrue((smoothed >= 0.0).all())
+
+    def test_no_label_weight_column_appears_under_an_inactive_weighting_strategy(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            self.assertEqual(model.label_weighting["default"]["strategy"], "none")
+            result = model.set_freqai_targets(zigzag(), {"pair": PAIR})
+            for column in WEIGHT_COLUMNS:
+                with self.subTest(column=column):
+                    self.assertNotIn(column, result.columns)
+
+    def test_the_label_parameters_drive_the_generated_labels(self):
+        with temporary_directory() as temp:
+            model = runtime(temp)
+            model.bot_start()
+            narrow = model.set_freqai_targets(zigzag(), {"pair": PAIR})
+            model.set_label_period_candles(PAIR, 24)
+            wide = model.set_freqai_targets(zigzag(), {"pair": PAIR})
+            self.assertTrue((narrow[EXTREMA_COLUMN] != 0.0).any())
+            self.assertEqual(wide[EXTREMA_COLUMN].fillna(0.0).ne(0.0).sum(), 0)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_suite_contract.py b/quickadapter/tests/test_suite_contract.py
new file mode 100644 (file)
index 0000000..86d3a82
--- /dev/null
@@ -0,0 +1,64 @@
+"""Guards the import identity and coverage denominator of the measured source."""
+
+import importlib
+import sys
+from pathlib import Path
+
+from qa_support import REPO_ROOT, QaTestCase
+
+QUICKADAPTER = REPO_ROOT / "quickadapter"
+MEASURED_SOURCE = QUICKADAPTER / "user_data"
+_STRATEGY_SOURCES = tuple(sorted((MEASURED_SOURCE / "strategies").glob("*.py")))
+_MODEL_SOURCES = tuple(sorted((MEASURED_SOURCE / "freqaimodels").glob("*.py")))
+# Strategies modules are imported by bare name, which is what production does. The regressor is
+# not on PYTHONPATH and is reachable only as a dotted path. Deriving the names from the tree is
+# what keeps this contract true when a source file is added.
+PRODUCTION_NAMES = tuple(
+    [src.stem for src in _STRATEGY_SOURCES]
+    + [f"quickadapter.user_data.freqaimodels.{src.stem}" for src in _MODEL_SOURCES]
+)
+# A dotted strategies import is banned, so neither branch below may ever build one.
+_FORBIDDEN_PREFIXES = ("quickadapter.user_data.strategies.", "quickadapter.tests.")
+
+# Imported here, at module scope, so the parity contract holds on its own: unittest discovery
+# imports every test module before running any test, so a lazy import inside a method would make
+# the parity assertion unconditionally true.
+for _name in PRODUCTION_NAMES:
+    importlib.import_module(_name)
+
+
+class SuiteContractTest(QaTestCase):
+    def test_a_every_source_file_has_a_loaded_module(self):
+        loaded = {
+            Path(module.__file__).resolve()
+            for module in list(sys.modules.values())
+            if getattr(module, "__file__", None)
+        }
+        for src in (*_STRATEGY_SOURCES, *_MODEL_SOURCES):
+            with self.subTest(module=src.name):
+                self.assertIn(src.resolve(), loaded)
+
+    def test_b_every_source_module_imports(self):
+        for name in PRODUCTION_NAMES:
+            with self.subTest(module=name):
+                self.assertIsNotNone(importlib.import_module(name))
+
+    def test_no_package_marker_under_quickadapter(self):
+        markers = sorted(
+            path.relative_to(QUICKADAPTER).as_posix() for path in QUICKADAPTER.rglob("__init__.py")
+        )
+        self.assertEqual(
+            [],
+            markers,
+            "a package marker makes the start directory a package, so the test modules lose "
+            "top-level status and `from qa_support import ...` fails under -t discovery",
+        )
+
+    def test_no_module_is_registered_under_a_second_name(self):
+        found = sorted(name for name in sys.modules if name.startswith(_FORBIDDEN_PREFIXES))
+        self.assertEqual(
+            [],
+            found,
+            "a shadow copy carries its own _LABEL_GENERATORS and _WARNED_CONFIG_DEPRECATIONS, so "
+            "every reset in qa_support would clean a module the code under test never touches",
+        )
diff --git a/quickadapter/tests/test_utils_canonical_defaults.py b/quickadapter/tests/test_utils_canonical_defaults.py
new file mode 100644 (file)
index 0000000..9f25deb
--- /dev/null
@@ -0,0 +1,432 @@
+"""Canonical defaults contract between every `*_SPECS` map, its `DEFAULTS_*` map and the per-label overlay; requires the Freqtrade QA image."""
+
+import logging
+from typing import Any
+
+import Utils
+from LabelTransformer import (
+    DEFAULTS_LABEL_PIPELINE,
+    DEFAULTS_LABEL_PREDICTION,
+    DEFAULTS_LABEL_SMOOTHING,
+    DEFAULTS_LABEL_WEIGHTING,
+    SMOOTHING_METHOD_MODES,
+    SMOOTHING_METHODS,
+    SMOOTHING_MODES,
+    get_label_column_config,
+)
+from qa_support import QaTestCase
+from Utils import (
+    _ParamSpec,
+    _validate_params,
+    get_label_kind_config,
+    get_label_pipeline_config,
+    get_label_prediction_config,
+    get_label_smoothing_config,
+    get_label_weighting_config,
+)
+
+LOGGER = logging.getLogger("test-canonical-defaults")
+
+LABEL_COLUMN = "&s-extrema"
+CONFIG_NAME = "label_smoothing"
+
+# The nine (specs, defaults) pairs whose key sets are equal. `_FIT_LIVE_PREDICTIONS_SPECS`
+# and `_REVERSAL_CONFIRMATION_SCALAR_SPECS` are deliberately not here: their defaults are
+# an inline literal and a documented strict subset, asserted separately below.
+EXACT_PAIRS: tuple[tuple[str, dict[str, Any]], ...] = (
+    ("_WEIGHTING_SPECS", DEFAULTS_LABEL_WEIGHTING),
+    ("_PIPELINE_SPECS", DEFAULTS_LABEL_PIPELINE),
+    ("_SMOOTHING_SPECS", DEFAULTS_LABEL_SMOOTHING),
+    ("_PREDICTION_SPECS", DEFAULTS_LABEL_PREDICTION),
+    ("_EXIT_PRICING_SPECS", Utils.DEFAULTS_EXIT_PRICING),
+    ("_CUSTOM_PROTECTIONS_SPECS", Utils.DEFAULTS_CUSTOM_PROTECTIONS),
+    ("_COOLDOWN_PROTECTION_SPECS", Utils.DEFAULTS_COOLDOWN_PROTECTION),
+    ("_DRAWDOWN_PROTECTION_SPECS", Utils.DEFAULTS_DRAWDOWN_PROTECTION),
+    ("_STOPLOSS_PROTECTION_SPECS", Utils.DEFAULTS_STOPLOSS_PROTECTION),
+)
+
+LABEL_KINDS: tuple[tuple[str, Any, dict[str, Any]], ...] = (
+    ("label_weighting", get_label_weighting_config, DEFAULTS_LABEL_WEIGHTING),
+    ("label_pipeline", get_label_pipeline_config, DEFAULTS_LABEL_PIPELINE),
+    ("label_smoothing", get_label_smoothing_config, DEFAULTS_LABEL_SMOOTHING),
+    ("label_prediction", get_label_prediction_config, DEFAULTS_LABEL_PREDICTION),
+)
+
+# One rejected value per kind: each is refused by its own spec, so the accessor must
+# substitute the shipped default rather than pass the value through.
+INVALID_VALUES: dict[str, tuple[str, Any]] = {
+    "label_weighting": ("softmax_temperature", -1),
+    "label_pipeline": ("gamma", 0),
+    "label_smoothing": ("window_candles", 5.0),
+    "label_prediction": ("method", "bogus"),
+}
+
+# One value per kind that its spec accepts but its `output_type` rewrites.
+COERCED_VALUES: dict[str, tuple[str, Any, Any, type]] = {
+    "label_weighting": ("fill_sigma_candles", 25, 25.0, float),
+    "label_pipeline": ("robust_quantiles", [0.1, 0.9], (0.1, 0.9), tuple),
+    "label_smoothing": ("beta", 1, 1.0, float),
+    "label_prediction": ("keep_fraction", 1, 1.0, float),
+}
+
+
+def specs_map(name: str) -> dict[str, _ParamSpec]:
+    return getattr(Utils, name)
+
+
+def discovered_specs() -> list[str]:
+    return sorted(
+        name
+        for name, value in vars(Utils).items()
+        if name.endswith("_SPECS")
+        and isinstance(value, dict)
+        and all(isinstance(spec, _ParamSpec) for spec in value.values())
+    )
+
+
+def smoothing_mode_mode_message(method: str, mode: str, config_name: str = CONFIG_NAME) -> str:
+    return (
+        f"Invalid {config_name} mode value {mode!r} for method {method!r}: "
+        f"supported values are {', '.join(SMOOTHING_METHOD_MODES[method])}"
+    )
+
+
+class UtilsCanonicalDefaultsTest(QaTestCase):
+    def test_every_specs_map_in_the_module_is_accounted_for(self):
+        expected = sorted(
+            [name for name, _ in EXACT_PAIRS]
+            + ["_FIT_LIVE_PREDICTIONS_SPECS", "_REVERSAL_CONFIRMATION_SCALAR_SPECS"]
+        )
+        self.assertEqual(discovered_specs(), expected)
+
+    def test_a_pair_of_maps_with_disagreeing_keys_would_silently_lose_a_default(self):
+        # A key present in one map and absent from the other is the whole failure this
+        # guards: `_validate_params` reads `defaults[param]` for every spec key, so a spec
+        # key with no default is a KeyError on every load, and a default key with no spec
+        # is a tunable no user can ever set.
+        for name, defaults in EXACT_PAIRS:
+            with self.subTest(specs=name):
+                self.assertEqual(sorted(specs_map(name)), sorted(defaults))
+
+    def test_every_shipped_default_satisfies_its_own_validator(self):
+        for name, defaults in EXACT_PAIRS:
+            specs = specs_map(name)
+            for key, value in defaults.items():
+                with self.subTest(specs=name, key=key):
+                    self.assertTrue(specs[key].validator(value), f"{name}.{key} = {value!r}")
+
+    def test_validating_the_defaults_rewrites_nothing_and_warns_about_nothing(self):
+        # A default that failed its own validator would be replaced by itself on every
+        # load — a warning per load, and a shipped constant that documents a value the
+        # loader can never accept.
+        for name, defaults in EXACT_PAIRS:
+            with self.subTest(specs=name):
+                with self.assertNoLogs(LOGGER, level="WARNING"):
+                    validated = _validate_params({}, LOGGER, name, specs_map(name), defaults)
+                self.assertEqual(validated, defaults)
+
+    def test_the_scalars_spec_map_covers_all_but_the_coupled_natr_pair(self):
+        # `get_reversal_confirmation_config` validates the (min, max) natr pair through
+        # `validate_range`, which `_validate_params` cannot express, so the specs map is
+        # a documented strict subset rather than an equal one.
+        specs = specs_map("_REVERSAL_CONFIRMATION_SCALAR_SPECS")
+        defaults = Utils.DEFAULTS_REVERSAL_CONFIRMATION
+        self.assertLess(specs.keys(), defaults.keys())
+        self.assertEqual(
+            sorted(defaults.keys() - specs.keys()),
+            ["max_natr_multiplier_fraction", "min_natr_multiplier_fraction"],
+        )
+        for key, value in defaults.items():
+            if key in specs:
+                with self.subTest(key=key):
+                    self.assertTrue(specs[key].validator(value))
+
+    def test_the_fit_live_predictions_default_is_declared_where_it_is_consumed(self):
+        specs = specs_map("_FIT_LIVE_PREDICTIONS_SPECS")
+        inline_defaults = {
+            "fit_live_predictions_candles": Utils.DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES
+        }
+        self.assertEqual(sorted(specs), sorted(inline_defaults))
+        with self.assertNoLogs(LOGGER, level="WARNING"):
+            self.assertEqual(
+                _validate_params({}, LOGGER, "freqai", specs, inline_defaults), inline_defaults
+            )
+
+    def test_the_label_kind_registry_wires_each_kind_to_its_own_pair(self):
+        self.assertEqual(
+            sorted(Utils._LABEL_KIND_REGISTRY),
+            sorted(kind for kind, _, _ in LABEL_KINDS),
+        )
+        for kind, _, defaults in LABEL_KINDS:
+            specs, registry_defaults, cross_field = Utils._LABEL_KIND_REGISTRY[kind]
+            with self.subTest(kind=kind):
+                self.assertIs(registry_defaults, defaults)
+                self.assertEqual(sorted(specs), sorted(defaults))
+                self.assertIs(cross_field is not None, kind == "label_smoothing")
+
+    def test_each_accessor_returns_the_defaults_for_an_empty_config(self):
+        for kind, accessor, defaults in LABEL_KINDS:
+            with self.subTest(kind=kind):
+                self.assertEqual(
+                    accessor({}, LOGGER),
+                    {"default": defaults, "columns": {}},
+                )
+
+    def test_an_absent_config_is_silent_and_a_non_mapping_one_warns(self):
+        # `as_config_section` treats `None` as "section not supplied" and only warns for a
+        # value that is present and not a mapping; both land on the shipped defaults.
+        for kind, accessor, defaults in LABEL_KINDS:
+            with self.subTest(kind=kind, config=None), self.assertNoLogs(LOGGER, level="WARNING"):
+                self.assertEqual(accessor(None, LOGGER)["default"], defaults)
+            for config in ("junk", 7, ["junk"]):
+                with (
+                    self.subTest(kind=kind, config=config),
+                    self.assertLogs(LOGGER, level="WARNING"),
+                ):
+                    self.assertEqual(accessor(config, LOGGER)["default"], defaults)
+
+    def test_each_accessor_substitutes_the_default_for_a_rejected_value(self):
+        for kind, accessor, defaults in LABEL_KINDS:
+            key, rejected = INVALID_VALUES[kind]
+            with self.subTest(kind=kind, key=key):
+                with self.assertLogs(LOGGER, level="WARNING") as captured:
+                    resolved = accessor({key: rejected}, LOGGER)["default"]
+                self.assertEqual(resolved[key], defaults[key])
+                self.assertIn(key, "\n".join(captured.output))
+
+    def test_each_accessor_applies_the_specified_output_type(self):
+        for kind, accessor, _ in LABEL_KINDS:
+            key, supplied, expected, expected_type = COERCED_VALUES[kind]
+            with self.subTest(kind=kind, key=key):
+                with self.assertNoLogs(LOGGER, level="WARNING"):
+                    resolved = accessor({key: supplied}, LOGGER)["default"][key]
+                self.assertIs(type(resolved), expected_type)
+                self.assertEqual(resolved, expected)
+
+    def test_an_unknown_key_is_dropped_rather_than_carried_through(self):
+        for kind, accessor, defaults in LABEL_KINDS:
+            with self.subTest(kind=kind):
+                self.assertEqual(
+                    accessor({"not_a_tunable": 1}, LOGGER)["default"],
+                    defaults,
+                )
+
+    def test_a_per_label_config_is_reported_as_default_plus_columns(self):
+        resolved = get_label_smoothing_config(
+            {"default": {"window_candles": 11}, "columns": {LABEL_COLUMN: {"beta": 2.0}}},
+            LOGGER,
+        )
+        self.assertEqual(sorted(resolved), ["columns", "default"])
+        self.assertEqual(resolved["default"]["window_candles"], 11)
+        self.assertEqual(resolved["default"]["beta"], DEFAULTS_LABEL_SMOOTHING["beta"])
+        self.assertEqual(resolved["columns"], {LABEL_COLUMN: {"beta": 2.0}})
+        # Sibling flat keys are not merged into either half.
+        with self.assertLogs(LOGGER, level="WARNING"):
+            flat = get_label_smoothing_config(
+                {"default": {}, "columns": {}, "window_candles": 99},
+                LOGGER,
+            )
+        self.assertEqual(flat["default"], DEFAULTS_LABEL_SMOOTHING)
+
+    def test_the_per_label_format_is_detected_by_the_presence_of_either_half(self):
+        for config in ({"default": {}}, {"columns": {}}, {"default": {}, "columns": {}}):
+            with self.subTest(config=config):
+                self.assertEqual(
+                    sorted(get_label_kind_config("label_weighting", config, LOGGER)),
+                    ["columns", "default"],
+                )
+
+    def test_an_unknown_column_key_is_ignored_and_a_non_mapping_half_is_dropped(self):
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            resolved = get_label_smoothing_config(
+                {"columns": {LABEL_COLUMN: "nope", "not-a-label": {"not_a_tunable": 1}}},
+                LOGGER,
+            )
+        self.assertEqual(resolved["columns"], {})
+        self.assertIn("not_a_tunable", "\n".join(captured.output))
+
+    def test_an_exact_pattern_beats_every_wildcard(self):
+        resolved = get_label_column_config(
+            LABEL_COLUMN,
+            {"window_candles": 5},
+            {
+                "*": {"window_candles": 1},
+                "&s-*": {"window_candles": 2},
+                "&s-ext*": {"window_candles": 3},
+                LABEL_COLUMN: {"window_candles": 4},
+            },
+        )
+        self.assertEqual(resolved["window_candles"], 4)
+
+    def test_wildcards_are_applied_in_ascending_specificity(self):
+        # Specificity is the count of non-glob characters, and the overlays are folded in
+        # from least to most specific, so the most specific wildcard has the last word.
+        self.assertEqual(
+            get_label_column_config(
+                LABEL_COLUMN,
+                {"window_candles": 5},
+                {
+                    "&s-ext*": {"window_candles": 3},
+                    "&s-*": {"window_candles": 2},
+                    "*": {"window_candles": 1},
+                },
+            ),
+            {"window_candles": 3},
+        )
+        self.assertEqual(
+            get_label_column_config(
+                LABEL_COLUMN,
+                {"window_candles": 5},
+                {"&s-*": {"window_candles": 2}, "*": {"window_candles": 1}},
+            ),
+            {"window_candles": 2},
+        )
+
+    def test_bracket_and_question_globs_are_wildcards_not_exact_patterns(self):
+        # `&s-extrem?` and `&s-[ex]xtrema` both match, but neither is exact, so the truly
+        # exact pattern must still win.
+        self.assertEqual(
+            get_label_column_config(
+                LABEL_COLUMN,
+                {"window_candles": 5},
+                {
+                    "&s-extrem?": {"window_candles": 1},
+                    "&s-[ex]xtrema": {"window_candles": 2},
+                    LABEL_COLUMN: {"window_candles": 3},
+                },
+            ),
+            {"window_candles": 3},
+        )
+        # Against each other they rank by literal characters: 9 for `&s-extrem?`, 7 for
+        # `&s-ext*`.
+        self.assertEqual(
+            get_label_column_config(
+                LABEL_COLUMN,
+                {"window_candles": 5},
+                {"&s-ext*": {"window_candles": 1}, "&s-extrem?": {"window_candles": 2}},
+            ),
+            {"window_candles": 2},
+        )
+
+    def test_patterns_of_equal_specificity_resolve_by_declaration_order(self):
+        # `&s-e*` and `&s-*a` each contribute three literal characters, so neither outranks
+        # the other: the stable sort keeps declaration order and the later overlay is folded
+        # in last, so the two orderings of the same pairs disagree.
+        first = {"&s-e*": {"w": 1}, "&s-*a": {"w": 2}}
+        second = {"&s-*a": {"w": 2}, "&s-e*": {"w": 1}}
+        self.assertEqual(get_label_column_config(LABEL_COLUMN, {"w": 0}, first), {"w": 2})
+        self.assertEqual(get_label_column_config(LABEL_COLUMN, {"w": 0}, second), {"w": 1})
+
+    def test_only_matching_patterns_contribute_and_defaults_survive(self):
+        self.assertEqual(
+            get_label_column_config(
+                "&-amplitude",
+                {"window_candles": 5, "beta": 8.0},
+                {LABEL_COLUMN: {"window_candles": 3}},
+            ),
+            {"window_candles": 5, "beta": 8.0},
+        )
+        # A matching pattern overlays only the keys it names.
+        self.assertEqual(
+            get_label_column_config(
+                LABEL_COLUMN,
+                {"window_candles": 5, "beta": 8.0},
+                {LABEL_COLUMN: {"beta": 2.0}},
+            ),
+            {"window_candles": 5, "beta": 2.0},
+        )
+
+    def test_the_default_config_is_deep_copied_into_the_result(self):
+        defaults = {"nested": {"a": [1, 2]}}
+        resolved = get_label_column_config(LABEL_COLUMN, defaults, {})
+        self.assertIsNot(resolved, defaults)
+        self.assertIsNot(resolved["nested"], defaults["nested"])
+        resolved["nested"]["a"].append(3)
+        resolved["nested"]["a"][0] = 99
+        self.assertEqual(defaults, {"nested": {"a": [1, 2]}})
+
+    def test_only_the_smoothing_method_mode_pair_is_cross_field_validated(self):
+        unconstrained = [m for m in SMOOTHING_METHODS if m not in SMOOTHING_METHOD_MODES]
+        self.assertTrue(unconstrained)
+        for method in unconstrained:
+            for mode in SMOOTHING_MODES:
+                with self.subTest(method=method, mode=mode):
+                    self.assertIsNone(
+                        Utils._validate_smoothing_method_mode(
+                            {"method": method, "mode": mode}, CONFIG_NAME
+                        )
+                    )
+
+    def test_the_method_mode_table_is_a_non_empty_subset_of_the_declared_modes(self):
+        self.assertLessEqual(set(SMOOTHING_METHOD_MODES), set(SMOOTHING_METHODS))
+        for method, modes in SMOOTHING_METHOD_MODES.items():
+            with self.subTest(method=method):
+                self.assertTrue(modes)
+                self.assertLessEqual(set(modes), set(SMOOTHING_MODES))
+
+    def test_a_mode_the_method_does_not_support_raises_rather_than_warns(self):
+        for method, modes in SMOOTHING_METHOD_MODES.items():
+            for mode in SMOOTHING_MODES:
+                config = {"method": method, "mode": mode}
+                if mode in modes:
+                    with self.subTest(method=method, mode=mode):
+                        self.assertIsNone(
+                            Utils._validate_smoothing_method_mode(config, CONFIG_NAME)
+                        )
+                else:
+                    with self.subTest(method=method, mode=mode):
+                        with self.assertRaises(ValueError) as raised:
+                            Utils._validate_smoothing_method_mode(config, CONFIG_NAME)
+                        self.assertEqual(
+                            str(raised.exception), smoothing_mode_mode_message(method, mode)
+                        )
+
+    def test_interp_is_valid_for_savgol_and_invalid_for_gaussian_filter1d(self):
+        # The only concrete disagreement between the two constrained methods, read off the
+        # table rather than assumed.
+        self.assertIn("interp", SMOOTHING_METHOD_MODES["savgol"])
+        self.assertNotIn("interp", SMOOTHING_METHOD_MODES["gaussian_filter1d"])
+        self.assertIsNone(
+            Utils._validate_smoothing_method_mode(
+                {"method": "savgol", "mode": "interp"}, CONFIG_NAME
+            )
+        )
+        with self.assertRaises(ValueError):
+            Utils._validate_smoothing_method_mode(
+                {"method": "gaussian_filter1d", "mode": "interp"}, CONFIG_NAME
+            )
+
+    def test_the_smoothing_accessor_raises_on_an_inconsistent_default(self):
+        with self.assertRaises(ValueError) as raised:
+            get_label_smoothing_config({"method": "gaussian_filter1d", "mode": "interp"}, LOGGER)
+        self.assertEqual(
+            str(raised.exception),
+            smoothing_mode_mode_message(
+                "gaussian_filter1d",
+                "interp",
+                f"{CONFIG_NAME} for label {LABEL_COLUMN!r}",
+            ),
+        )
+
+    def test_the_cross_field_check_reads_the_overlaid_label_config(self):
+        # The check runs on `get_label_column_config` applied to each shipped label column,
+        # so a per-column override can repair an inconsistent default for that column.
+        resolved = get_label_smoothing_config(
+            {
+                "default": {"method": "gaussian_filter1d", "mode": "interp"},
+                "columns": {LABEL_COLUMN: {"mode": "mirror"}},
+            },
+            LOGGER,
+        )
+        self.assertEqual(resolved["default"]["method"], "gaussian_filter1d")
+        self.assertEqual(resolved["default"]["mode"], "interp")
+        self.assertEqual(resolved["columns"], {LABEL_COLUMN: {"mode": "mirror"}})
+        # The same inconsistency left standing on the column raises.
+        with self.assertRaises(ValueError):
+            get_label_smoothing_config(
+                {
+                    "default": {"method": "gaussian_filter1d", "mode": "interp"},
+                    "columns": {LABEL_COLUMN: {"mode": "interp"}},
+                },
+                LOGGER,
+            )
diff --git a/quickadapter/tests/test_utils_config_migration.py b/quickadapter/tests/test_utils_config_migration.py
new file mode 100644 (file)
index 0000000..b1c16b7
--- /dev/null
@@ -0,0 +1,250 @@
+"""Deprecated-config migration: in-place rewrite, precedence and warn-once."""
+
+import logging
+import unittest
+
+from qa_support import QaTestCase
+from Utils import (
+    _MISSING,
+    CONFIG_DEPRECATIONS,
+    _delete_path,
+    _get_path,
+    _set_path,
+    as_config_section,
+    as_dict,
+    migrate_config,
+)
+
+LOGGER = logging.getLogger("test-config-migration")
+
+
+class UtilsConfigMigrationTest(QaTestCase):
+    def test_a_renamed_section_moves_the_value_and_drops_the_old_path(self):
+        # extrema_smoothing -> label_smoothing, with a key that chains into no further rename.
+        config = {"freqai": {"extrema_smoothing": {"factor": 0.5}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        self.assertNotIn("extrema_smoothing", config["freqai"])
+        self.assertEqual(config["freqai"]["label_smoothing"], {"factor": 0.5})
+        self.assertIn("'label_smoothing' instead", captured.output[0])
+
+    def test_b_migration_rewrites_the_callers_dict_in_place(self):
+        config = {"freqai": {"extrema_weighting": {"gamma": 0.5}}}
+        section = config["freqai"]["extrema_weighting"]
+
+        result = migrate_config(config, LOGGER)
+
+        # The shared-config-base hazard is the mutation, not a returned copy: the caller still
+        # holds the very object the value lived in, and finds it drained, so every other holder
+        # of that dict is affected too.
+        self.assertIsNone(result)
+        self.assertIs(config["freqai"]["label_weighting"], section)
+        self.assertEqual(section, {})
+        self.assertEqual(config["freqai"]["label_pipeline"], {"gamma": 0.5})
+
+    def test_c_a_renamed_key_within_a_section_takes_the_short_name_in_its_warning(self):
+        config = {"freqai": {"label_smoothing": {"window": 4}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        self.assertEqual(config["freqai"]["label_smoothing"], {"window_candles": 4})
+        self.assertIn("'window_candles' instead", captured.output[0])
+
+    def test_d_a_key_move_follows_the_section_rename_that_precedes_it(self):
+        config = {"freqai": {"label_weighting": {"standardization": True}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        self.assertEqual(config["freqai"]["label_pipeline"], {"standardization": True})
+        self.assertEqual(config["freqai"]["label_weighting"], {})
+        # The sections differ, so the warning names the whole new path rather than the bare key.
+        self.assertIn("'freqai.label_pipeline.standardization' instead", captured.output[0])
+
+    def test_e_a_key_already_at_its_new_path_is_left_alone(self):
+        config = {"freqai": {"label_pipeline": {"gamma": 0.7}}}
+
+        with self.assertNoLogs(LOGGER, level="WARNING"):
+            migrate_config(config, LOGGER)
+
+        self.assertEqual(config, {"freqai": {"label_pipeline": {"gamma": 0.7}}})
+
+    def test_f_a_deleted_key_is_removed_and_its_value_dropped(self):
+        config = {"exit_pricing": {"thresholds_calibration": {"low": 0.5}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        self.assertEqual(config, {"exit_pricing": {}})
+        self.assertIn("is obsolete and ignored", captured.output[0])
+        self.assertIn("armed volatility-scaled retracement", captured.output[0])
+
+    def test_g_a_predicated_entry_warns_without_touching_the_key(self):
+        config = {"freqai": {"feature_parameters": {"causal_mode": False}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        # The one entry that warns about a value rather than about a path: it has no new path,
+        # so the key survives the migration carrying the very setting the message is about.
+        self.assertEqual(config, {"freqai": {"feature_parameters": {"causal_mode": False}}})
+        self.assertIn("causal_mode=false is deprecated", captured.output[0])
+        self.assertIn("label lookahead leakage possible", captured.output[0])
+
+    def test_h_the_new_value_wins_when_both_paths_are_present(self):
+        config = {"freqai": {"extrema_smoothing": {"old": 1}, "label_smoothing": {"new": 2}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        self.assertNotIn("extrema_smoothing", config["freqai"])
+        self.assertEqual(config["freqai"]["label_smoothing"], {"new": 2})
+        self.assertIn("using 'label_smoothing'", captured.output[0])
+
+    def test_i_a_renamed_chain_resolves_to_the_final_key_carrying_the_current_value(self):
+        config = {
+            "exit_pricing": {
+                "trade_price_target": "candle_open",
+                "trade_price_target_method": "candle_close",
+            }
+        }
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        # Two entries share a path: the first pass discards the superseded name and keeps the
+        # value already sitting at the new one, which the second pass then carries on to the
+        # final name. The winner survives the whole chain.
+        self.assertEqual(config, {"exit_pricing": {"trade_natr_method": "candle_close"}})
+        self.assertEqual(len(captured.output), 2)
+
+    def test_j_a_discarded_rename_creates_no_destination_section(self):
+        config = {"freqai": {"extrema_smoothing": {"old": 1}, "label_smoothing": {"new": 2}}}
+
+        with self.assertLogs(LOGGER, level="WARNING"):
+            migrate_config(config, LOGGER)
+
+        self.assertEqual(sorted(config["freqai"]), ["label_smoothing"])
+
+    def test_k_the_deprecation_table_is_a_tuple_of_well_formed_unique_entries(self):
+        self.assertIsInstance(CONFIG_DEPRECATIONS, tuple)
+        self.assertEqual(
+            len({entry[0] for entry in CONFIG_DEPRECATIONS}),
+            len(CONFIG_DEPRECATIONS),
+            "two entries share an old path, so the second can never fire",
+        )
+        for old_path, new_path, predicate, guidance in CONFIG_DEPRECATIONS:
+            with self.subTest(old_path=old_path):
+                self.assertIsInstance(old_path, str)
+                self.assertIn(".", old_path)
+                self.assertNotEqual(old_path, new_path)
+                self.assertTrue(new_path is None or (isinstance(new_path, str) and "." in new_path))
+                self.assertTrue(predicate is None or callable(predicate))
+                self.assertTrue(guidance is None or isinstance(guidance, str))
+                # A key with nowhere to go must say why; a plain rename has nothing to explain.
+                self.assertEqual(new_path is None, guidance is not None)
+
+    def test_l_section_renames_are_ordered_before_the_key_moves_that_feed_on_them(self):
+        # An entry whose old path sits under an earlier entry's new path is unreachable unless
+        # the earlier one has already run, which is what the ordering comment on the table
+        # guarantees. Reordering the table would break every pair this finds.
+        inherited = 0
+        for index, (old_path, _, _, _) in enumerate(CONFIG_DEPRECATIONS):
+            for earlier_index, (_, earlier_new, _, _) in enumerate(CONFIG_DEPRECATIONS[:index]):
+                if earlier_new is None:
+                    continue
+                if old_path == earlier_new or old_path.startswith(f"{earlier_new}."):
+                    inherited += 1
+                    self.assertLess(
+                        earlier_index,
+                        index,
+                        f"{old_path} can only be reached after {earlier_index} is applied",
+                    )
+        self.assertGreater(inherited, 0, "the table no longer encodes a rename chain")
+
+    def test_m_a_deprecation_path_warns_once_per_process_across_config_objects(self):
+        first = {"freqai": {"extrema_smoothing": {"factor": 0.5}}}
+        second = {"freqai": {"extrema_smoothing": {"factor": 0.8}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(first, LOGGER)
+            migrate_config(second, LOGGER)
+
+        # Both objects are migrated; the notice is keyed on the path, so only the first caller
+        # to reach it is told. That is the whole point of the warned-once registry.
+        self.assertEqual(len(captured.output), 1)
+        self.assertEqual(second["freqai"]["label_smoothing"], {"factor": 0.8})
+
+    def test_n_two_deprecation_paths_warn_independently(self):
+        config = {"freqai": {"extrema_smoothing": {"factor": 0.5}, "predictions_extrema": {"q": 9}}}
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            migrate_config(config, LOGGER)
+
+        self.assertEqual(len(captured.output), 2)
+        self.assertTrue(any("extrema_smoothing" in line for line in captured.output))
+        self.assertTrue(any("predictions_extrema" in line for line in captured.output))
+        self.assertEqual(config["freqai"]["label_smoothing"], {"factor": 0.5})
+        self.assertEqual(config["freqai"]["label_prediction"], {"q": 9})
+
+    def test_o_get_path_walks_nested_mappings_and_signals_a_miss_with_the_sentinel(self):
+        config = {"freqai": {"label_pipeline": {"gamma": 0.5}}, "flat": 0, "none": None}
+
+        self.assertEqual(_get_path(config, "freqai.label_pipeline.gamma"), 0.5)
+        self.assertEqual(_get_path(config, "freqai"), {"label_pipeline": {"gamma": 0.5}})
+        self.assertIs(_get_path(config, "freqai.label_pipeline.absent"), _MISSING)
+        self.assertIs(_get_path(config, "absent.branch.key"), _MISSING)
+        # A scalar is not a branch: the walk stops instead of subscripting it.
+        self.assertIs(_get_path(config, "flat.deeper"), _MISSING)
+        # A stored None is a value, not an absence, and must not be confused with the sentinel.
+        self.assertIsNone(_get_path(config, "none"))
+        self.assertIsNot(_get_path(config, "none"), _MISSING)
+
+    def test_p_set_path_creates_the_intermediate_levels(self):
+        config: dict = {}
+        self.assertIsNone(_set_path(config, "a.b.c", 5))
+        self.assertEqual(config, {"a": {"b": {"c": 5}}})
+
+        config = {"enabled": False}
+        _set_path(config, "enabled", True)
+        self.assertEqual(config, {"enabled": True})
+
+        config = {"a": {"keep": 1}}
+        _set_path(config, "a.b", 2)
+        self.assertEqual(config, {"a": {"keep": 1, "b": 2}})
+
+    def test_q_delete_path_tolerates_a_missing_path(self):
+        config = {"a": {"b": 1}, "flat": 0}
+
+        self.assertTrue(_delete_path(config, "a.b"))
+        self.assertEqual(config, {"a": {}, "flat": 0})
+        self.assertFalse(_delete_path(config, "a.b"))
+        self.assertFalse(_delete_path(config, "absent.branch.key"))
+        self.assertFalse(_delete_path(config, "flat.deeper"))
+        self.assertEqual(config, {"a": {}, "flat": 0})
+
+    def test_r_as_config_section_normalizes_a_non_mapping_with_a_warning(self):
+        section = {"gamma": 0.5}
+        with self.assertNoLogs(LOGGER, level="WARNING"):
+            self.assertIs(as_config_section(section, "freqai.label_weighting", LOGGER), section)
+            # None is an absent section, not a malformed one, so it normalizes silently.
+            self.assertEqual(as_config_section(None, "freqai.label_weighting", LOGGER), {})
+
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            result = as_config_section(0.5, "freqai.label_weighting", LOGGER)
+        self.assertEqual(result, {})
+        self.assertIn("must be a mapping, using defaults", captured.output[0])
+
+    def test_s_as_dict_passes_a_mapping_through_by_identity_and_normalizes_the_rest(self):
+        section = {"gamma": 0.5}
+        self.assertIs(as_dict(section), section)
+        for value in (None, 0.5, "gamma", [("gamma", 0.5)]):
+            with self.subTest(value=value):
+                self.assertEqual(as_dict(value), {})
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_config_resolution.py b/quickadapter/tests/test_utils_config_resolution.py
new file mode 100644 (file)
index 0000000..18b0cfb
--- /dev/null
@@ -0,0 +1,688 @@
+"""Strategy-facing config resolvers: defaults precedence, warning text, and per-call isolation."""
+
+import copy
+import logging
+import unittest
+from collections.abc import Iterator
+from contextlib import contextmanager
+from typing import Any
+
+import numpy as np
+from qa_support import QaTestCase
+from Utils import (
+    DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES,
+    DEFAULTS_CUSTOM_PROTECTIONS,
+    DEFAULTS_EXIT_PRICING,
+    DEFAULTS_REVERSAL_CONFIRMATION,
+    get_causal_mode,
+    get_custom_protections_config,
+    get_exit_pricing_config,
+    get_fit_live_predictions_candles,
+    get_label_horizon_candles,
+    get_reversal_confirmation_config,
+    normalize_fit_live_predictions_config,
+)
+
+CANONICAL_EXIT_PRICING: dict[str, Any] = {
+    "trade_natr_method": "moving_average",
+    "final_take_profit_retracement_fraction": 0.25,
+}
+CANONICAL_PROTECTIONS: dict[str, Any] = {
+    "trade_duration_candles": 72,
+    "lookback_period_fraction": 0.5,
+    "cooldown": {"enabled": True, "stop_duration_candles": 4},
+    "drawdown": {"enabled": True, "max_allowed_drawdown": 0.2},
+    "stoploss": {"enabled": True},
+}
+CANONICAL_REVERSAL: dict[str, Any] = {
+    "lookback_period_candles": 0,
+    "decay_fraction": 0.5,
+    "min_natr_multiplier_fraction": 0.0095,
+    "max_natr_multiplier_fraction": 0.0125,
+}
+
+
+class _WarningSink(logging.Handler):
+    def __init__(self, sink: list[str]) -> None:
+        super().__init__(level=logging.NOTSET)
+        self._sink = sink
+
+    def emit(self, record: logging.LogRecord) -> None:
+        self._sink.append(record.getMessage())
+
+
+@contextmanager
+def recorded_warnings() -> Iterator[tuple[logging.Logger, list[str]]]:
+    """Yield a logger and the list its WARNING messages are appended to, call shape: log, sink."""
+    sink: list[str] = []
+    logger = logging.getLogger("quickadapter.tests.config_resolution")
+    handler = _WarningSink(sink)
+    previous_propagate = logger.propagate
+    logger.propagate = False
+    logger.setLevel(logging.NOTSET)
+    logger.addHandler(handler)
+    try:
+        yield logger, sink
+    finally:
+        logger.removeHandler(handler)
+        logger.propagate = previous_propagate
+
+
+class UtilsConfigResolutionTest(QaTestCase):
+    def test_exit_pricing_absent_section_resolves_to_canonical_defaults(self) -> None:
+        for section in (None, {}):
+            with self.subTest(section=section):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_exit_pricing_config(section, logger)
+                self.assertEqual(CANONICAL_EXIT_PRICING, resolved)
+                self.assertEqual([], sink)
+
+    def test_exit_pricing_non_mapping_section_warns_with_the_section_name(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_exit_pricing_config(7, logger)
+        self.assertEqual(CANONICAL_EXIT_PRICING, resolved)
+        self.assertEqual(["Invalid exit_pricing value 7: must be a mapping, using defaults"], sink)
+
+    def test_exit_pricing_unknown_natr_method_falls_back_with_the_enum_message(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_exit_pricing_config({"trade_natr_method": "nope"}, logger)
+        self.assertEqual(CANONICAL_EXIT_PRICING, resolved)
+        self.assertEqual(
+            [
+                "Invalid exit_pricing trade_natr_method value 'nope': supported values are "
+                "moving_average, quantile_interpolation, weighted_average, "
+                "using default 'moving_average'"
+            ],
+            sink,
+        )
+
+    def test_exit_pricing_every_supported_natr_method_is_preserved(self) -> None:
+        for method in ("moving_average", "quantile_interpolation", "weighted_average"):
+            with self.subTest(method=method):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_exit_pricing_config({"trade_natr_method": method}, logger)
+                self.assertEqual(method, resolved["trade_natr_method"])
+                self.assertEqual([], sink)
+
+    def test_exit_pricing_retracement_fraction_range_is_half_open_at_zero(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            self.assertEqual(
+                1.0,
+                get_exit_pricing_config({"final_take_profit_retracement_fraction": 1}, logger)[
+                    "final_take_profit_retracement_fraction"
+                ],
+            )
+        self.assertEqual([], sink)
+
+        for rejected in (0, 0.0, -0.5, None, True, "0.25"):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_exit_pricing_config(
+                        {"final_take_profit_retracement_fraction": rejected}, logger
+                    )
+                self.assertEqual(0.25, resolved["final_take_profit_retracement_fraction"])
+                self.assertEqual(
+                    [
+                        f"Invalid exit_pricing final_take_profit_retracement_fraction value "
+                        f"{rejected!r}: must be a finite number > 0 <= 1, using default 0.25"
+                    ],
+                    sink,
+                )
+
+    def test_exit_pricing_returns_a_fresh_dict_each_call(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            first = get_exit_pricing_config({}, logger)
+            second = get_exit_pricing_config({}, logger)
+        self.assertEqual([], sink)
+        first["trade_natr_method"] = "weighted_average"
+        self.assertIsNot(first, second)
+        self.assertEqual("moving_average", second["trade_natr_method"])
+        self.assertEqual("moving_average", DEFAULTS_EXIT_PRICING["trade_natr_method"])
+
+    def test_custom_protections_absent_section_resolves_to_canonical_defaults(self) -> None:
+        for section in (None, {}):
+            with self.subTest(section=section):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_custom_protections_config(section, logger)
+                self.assertEqual(CANONICAL_PROTECTIONS, resolved)
+                self.assertEqual([], sink)
+
+    def test_custom_protections_non_mapping_section_warns_with_the_section_name(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_custom_protections_config(7, logger)
+        self.assertEqual(CANONICAL_PROTECTIONS, resolved)
+        self.assertEqual(
+            ["Invalid custom_protections value 7: must be a mapping, using defaults"], sink
+        )
+
+    def test_custom_protections_top_level_fields_fall_back_with_their_constraints(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_custom_protections_config(
+                {"trade_duration_candles": 0, "lookback_period_fraction": 0}, logger
+            )
+        self.assertEqual(72, resolved["trade_duration_candles"])
+        self.assertEqual(0.5, resolved["lookback_period_fraction"])
+        self.assertEqual(
+            [
+                "Invalid custom_protections trade_duration_candles value 0: "
+                "must be an integer >= 1, using default 72",
+                "Invalid custom_protections lookback_period_fraction value 0: "
+                "must be a finite number > 0 <= 1, using default 0.5",
+            ],
+            sink,
+        )
+
+    def test_custom_protections_top_level_fields_reject_non_integers(self) -> None:
+        for rejected in (True, 5.0, "5", None):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_custom_protections_config(
+                        {"trade_duration_candles": rejected}, logger
+                    )
+                self.assertEqual(72, resolved["trade_duration_candles"])
+                self.assertEqual(
+                    [
+                        f"Invalid custom_protections trade_duration_candles value {rejected!r}: "
+                        f"must be an integer >= 1, using default 72"
+                    ],
+                    sink,
+                )
+
+    def test_custom_protections_sub_configs_fall_back_independently(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_custom_protections_config(
+                {
+                    "cooldown": {"enabled": "yes", "stop_duration_candles": 0},
+                    "drawdown": {"enabled": 1, "max_allowed_drawdown": 0.2},
+                    "stoploss": {"enabled": None},
+                },
+                logger,
+            )
+        self.assertEqual({"enabled": True, "stop_duration_candles": 4}, resolved["cooldown"])
+        self.assertEqual({"enabled": True, "max_allowed_drawdown": 0.2}, resolved["drawdown"])
+        self.assertEqual({"enabled": True}, resolved["stoploss"])
+        self.assertEqual(
+            [
+                "Invalid custom_protections.cooldown enabled value 'yes': "
+                "must be a boolean, using default True",
+                "Invalid custom_protections.cooldown stop_duration_candles value 0: "
+                "must be an integer >= 1, using default 4",
+                "Invalid custom_protections.drawdown enabled value 1: "
+                "must be a boolean, using default True",
+                "Invalid custom_protections.stoploss enabled value None: "
+                "must be a boolean, using default True",
+            ],
+            sink,
+        )
+
+    def test_custom_protections_scalar_sub_sections_fall_back_to_their_own_defaults(self) -> None:
+        for sub_name in ("cooldown", "drawdown", "stoploss"):
+            with self.subTest(sub_name=sub_name):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_custom_protections_config({sub_name: 3}, logger)
+                self.assertEqual(CANONICAL_PROTECTIONS[sub_name], resolved[sub_name])
+                self.assertEqual(
+                    [
+                        f"Invalid custom_protections.{sub_name} value 3: "
+                        f"must be a mapping, using defaults"
+                    ],
+                    sink,
+                )
+
+    def test_custom_protections_drawdown_range_is_open_at_both_ends(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_custom_protections_config(
+                {"drawdown": {"max_allowed_drawdown": 0.999}}, logger
+            )
+        self.assertEqual(0.999, resolved["drawdown"]["max_allowed_drawdown"])
+        self.assertEqual([], sink)
+
+        for rejected in (0, 0.0, 1.0, 1.5, -0.1, True, "0.2"):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_custom_protections_config(
+                        {"drawdown": {"max_allowed_drawdown": rejected}}, logger
+                    )
+                self.assertEqual(0.2, resolved["drawdown"]["max_allowed_drawdown"])
+                self.assertEqual(
+                    [
+                        f"Invalid custom_protections.drawdown max_allowed_drawdown value "
+                        f"{rejected!r}: must be a finite number > 0 < 1, using default 0.2"
+                    ],
+                    sink,
+                )
+
+    def test_custom_protections_sub_configs_can_be_disabled_independently(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_custom_protections_config(
+                {
+                    "cooldown": {"enabled": False},
+                    "drawdown": {"enabled": False},
+                    "stoploss": {"enabled": False},
+                },
+                logger,
+            )
+        self.assertEqual([], sink)
+        self.assertEqual(
+            {
+                "trade_duration_candles": 72,
+                "lookback_period_fraction": 0.5,
+                "cooldown": {"enabled": False, "stop_duration_candles": 4},
+                "drawdown": {"enabled": False, "max_allowed_drawdown": 0.2},
+                "stoploss": {"enabled": False},
+            },
+            resolved,
+        )
+
+    def test_custom_protections_returns_fresh_nested_dicts_each_call(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            first = get_custom_protections_config({}, logger)
+            second = get_custom_protections_config({}, logger)
+        self.assertEqual([], sink)
+        first["cooldown"]["enabled"] = "leaked"
+        first["drawdown"]["max_allowed_drawdown"] = 0.99
+        self.assertIsNot(first, second)
+        self.assertIsNot(first["cooldown"], second["cooldown"])
+        self.assertEqual({"enabled": True, "stop_duration_candles": 4}, second["cooldown"])
+        self.assertEqual(0.2, second["drawdown"]["max_allowed_drawdown"])
+        self.assertEqual(
+            {"trade_duration_candles": 72, "lookback_period_fraction": 0.5},
+            DEFAULTS_CUSTOM_PROTECTIONS,
+        )
+
+    def test_fit_live_predictions_candles_defaults_when_the_key_is_absent(self) -> None:
+        for section in (None, {}, {"enabled": True}):
+            with self.subTest(section=section):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_fit_live_predictions_candles(section, logger)
+                self.assertEqual(DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES, resolved)
+                self.assertEqual(100, resolved)
+                self.assertEqual([], sink)
+
+    def test_fit_live_predictions_candles_preserves_a_positive_integer(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_fit_live_predictions_candles(
+                {"fit_live_predictions_candles": 250}, logger
+            )
+        self.assertEqual(250, resolved)
+        self.assertIsInstance(resolved, int)
+        self.assertEqual([], sink)
+
+    def test_fit_live_predictions_candles_rejects_every_non_integer_spelling(self) -> None:
+        for rejected in (0, -1, 250.0, np.int64(250), True, "250", None, [250]):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_fit_live_predictions_candles(
+                        {"fit_live_predictions_candles": rejected}, logger
+                    )
+                self.assertEqual(100, resolved)
+                self.assertEqual(
+                    [
+                        f"Invalid freqai fit_live_predictions_candles value {rejected!r}: "
+                        f"must be an integer >= 1, using default 100"
+                    ],
+                    sink,
+                )
+
+    def test_fit_live_predictions_candles_non_mapping_section_warns_once(self) -> None:
+        for section in (7, [1]):
+            with self.subTest(section=section):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_fit_live_predictions_candles(section, logger)
+                self.assertEqual(100, resolved)
+                self.assertEqual(
+                    [f"Invalid freqai value {section!r}: must be a mapping, using defaults"], sink
+                )
+
+    def test_normalize_is_a_silent_no_op_without_a_nonempty_freqai_section(self) -> None:
+        for config in ({}, {"freqai": {}}, {"freqai": None}, {"freqai": 42}, {"freqai": [1]}):
+            with self.subTest(config=config):
+                snapshot = copy.deepcopy(config)
+                with recorded_warnings() as (logger, sink):
+                    returned = normalize_fit_live_predictions_config(config, logger)
+                self.assertIsNone(returned)
+                self.assertEqual([], sink)
+                self.assertEqual(snapshot, config)
+
+    def test_normalize_rewrites_a_bare_int_value_in_place(self) -> None:
+        config = {"freqai": {"fit_live_predictions_candles": 250}}
+        with recorded_warnings() as (logger, sink):
+            returned = normalize_fit_live_predictions_config(config, logger)
+        self.assertIsNone(returned)
+        self.assertEqual([], sink)
+        self.assertEqual(250, config["freqai"]["fit_live_predictions_candles"])
+        self.assertIsInstance(config["freqai"]["fit_live_predictions_candles"], int)
+
+    def test_normalize_adds_the_key_when_the_freqai_section_omits_it(self) -> None:
+        config = {"freqai": {"enabled": True}}
+        with recorded_warnings() as (logger, sink):
+            normalize_fit_live_predictions_config(config, logger)
+        self.assertEqual({"enabled": True, "fit_live_predictions_candles": 100}, config["freqai"])
+        self.assertEqual([], sink)
+
+    def test_normalize_replaces_an_invalid_value_and_warns(self) -> None:
+        for rejected in ("250", 0, 250.0, np.int64(250), True):
+            with self.subTest(rejected=rejected):
+                config = {"freqai": {"fit_live_predictions_candles": rejected}}
+                with recorded_warnings() as (logger, sink):
+                    normalize_fit_live_predictions_config(config, logger)
+                self.assertEqual(100, config["freqai"]["fit_live_predictions_candles"])
+                self.assertEqual(
+                    [
+                        f"Invalid freqai fit_live_predictions_candles value {rejected!r}: "
+                        f"must be an integer >= 1, using default 100"
+                    ],
+                    sink,
+                )
+
+    def test_reversal_confirmation_absent_section_resolves_to_canonical_defaults(self) -> None:
+        for section in (None, {}):
+            with self.subTest(section=section):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(section, logger)
+                self.assertEqual(CANONICAL_REVERSAL, resolved)
+                self.assertEqual([], sink)
+
+    def test_reversal_confirmation_non_mapping_section_warns_with_the_section_name(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_reversal_confirmation_config("x", logger)
+        self.assertEqual(CANONICAL_REVERSAL, resolved)
+        self.assertEqual(
+            ["Invalid reversal_confirmation value 'x': must be a mapping, using defaults"], sink
+        )
+
+    def test_reversal_confirmation_scalar_fields_fall_back_with_their_constraints(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_reversal_confirmation_config(
+                {"lookback_period_candles": -1, "decay_fraction": 0}, logger
+            )
+        self.assertEqual(0, resolved["lookback_period_candles"])
+        self.assertEqual(0.5, resolved["decay_fraction"])
+        self.assertEqual(
+            [
+                "Invalid reversal_confirmation lookback_period_candles value -1: "
+                "must be an integer >= 0, using default 0",
+                "Invalid reversal_confirmation decay_fraction value 0: "
+                "must be a finite number > 0 <= 1, using default 0.5",
+            ],
+            sink,
+        )
+
+    def test_reversal_confirmation_scalar_field_bounds(self) -> None:
+        for value in (0, 10):
+            with self.subTest(value=value):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(
+                        {"lookback_period_candles": value}, logger
+                    )
+                self.assertEqual(value, resolved["lookback_period_candles"])
+                self.assertEqual([], sink)
+
+        for rejected in (True, 10.0, np.int64(10), "10", None):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(
+                        {"lookback_period_candles": rejected}, logger
+                    )
+                self.assertEqual(0, resolved["lookback_period_candles"])
+                self.assertEqual(
+                    [
+                        f"Invalid reversal_confirmation lookback_period_candles value "
+                        f"{rejected!r}: must be an integer >= 0, using default 0"
+                    ],
+                    sink,
+                )
+
+    def test_reversal_confirmation_decay_fraction_range_is_half_open_at_zero(self) -> None:
+        for value in (1, 0.25):
+            with self.subTest(value=value):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config({"decay_fraction": value}, logger)
+                self.assertEqual(float(value), resolved["decay_fraction"])
+                self.assertEqual([], sink)
+
+        for rejected in (0, 0.0, -0.1, 1.01, True, None):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(
+                        {"decay_fraction": rejected}, logger
+                    )
+                self.assertEqual(0.5, resolved["decay_fraction"])
+                self.assertEqual(
+                    [
+                        f"Invalid reversal_confirmation decay_fraction value {rejected!r}: "
+                        f"must be a finite number > 0 <= 1, using default 0.5"
+                    ],
+                    sink,
+                )
+
+    def test_reversal_confirmation_inverted_pair_falls_back_to_both_defaults(self) -> None:
+        for pair in ((0.05, 0.01), (0.01, 0.01), (0.005, 0.0), (0.0095, 0.0)):
+            with self.subTest(pair=pair):
+                supplied_min, supplied_max = pair
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(
+                        {
+                            "min_natr_multiplier_fraction": supplied_min,
+                            "max_natr_multiplier_fraction": supplied_max,
+                        },
+                        logger,
+                    )
+                self.assertEqual(0.0095, resolved["min_natr_multiplier_fraction"])
+                self.assertEqual(0.0125, resolved["max_natr_multiplier_fraction"])
+                self.assertEqual(
+                    [
+                        "Invalid natr_multiplier_fraction ordering: must have "
+                        "min_natr_multiplier_fraction < max_natr_multiplier_fraction, "
+                        f"got min_natr_multiplier_fraction={supplied_min!r}, "
+                        f"max_natr_multiplier_fraction={supplied_max!r}, "
+                        "using defaults 0.0095, 0.0125",
+                        "Invalid natr_multiplier_fraction range "
+                        f"(min_natr_multiplier_fraction={supplied_min!r}, "
+                        f"max_natr_multiplier_fraction={supplied_max!r}), "
+                        "using (0.0095, 0.0125)",
+                    ],
+                    sink,
+                )
+
+    def test_reversal_confirmation_one_invalid_bound_keeps_the_other(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_reversal_confirmation_config(
+                {"min_natr_multiplier_fraction": -1, "max_natr_multiplier_fraction": 0.02}, logger
+            )
+        self.assertEqual(0.0095, resolved["min_natr_multiplier_fraction"])
+        self.assertEqual(0.02, resolved["max_natr_multiplier_fraction"])
+        self.assertEqual(
+            [
+                "Invalid min_natr_multiplier_fraction -1: "
+                "must be finite non-negative numeric <= 1, using default 0.0095",
+                "Invalid natr_multiplier_fraction range "
+                "(min_natr_multiplier_fraction=-1, max_natr_multiplier_fraction=0.02), "
+                "using (0.0095, 0.02)",
+            ],
+            sink,
+        )
+
+    def test_reversal_confirmation_invalid_upper_bound_keeps_the_lower(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_reversal_confirmation_config(
+                {"min_natr_multiplier_fraction": 0.01, "max_natr_multiplier_fraction": "x"}, logger
+            )
+        self.assertEqual(0.01, resolved["min_natr_multiplier_fraction"])
+        self.assertEqual(0.0125, resolved["max_natr_multiplier_fraction"])
+        self.assertEqual(
+            [
+                "Invalid max_natr_multiplier_fraction 'x': "
+                "must be finite non-negative numeric <= 1, using default 0.0125",
+                "Invalid natr_multiplier_fraction range "
+                "(min_natr_multiplier_fraction=0.01, max_natr_multiplier_fraction='x'), "
+                "using (0.01, 0.0125)",
+            ],
+            sink,
+        )
+
+    def test_reversal_confirmation_non_finite_and_numpy_bounds_fall_back(self) -> None:
+        for rejected in (float("nan"), float("inf"), np.int64(1), True):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(
+                        {"min_natr_multiplier_fraction": rejected}, logger
+                    )
+                self.assertEqual(0.0095, resolved["min_natr_multiplier_fraction"])
+                self.assertEqual(0.0125, resolved["max_natr_multiplier_fraction"])
+                self.assertEqual(
+                    [
+                        f"Invalid min_natr_multiplier_fraction {rejected!r}: "
+                        f"must be finite non-negative numeric <= 1, using default 0.0095",
+                        "Invalid natr_multiplier_fraction range "
+                        f"(min_natr_multiplier_fraction={rejected!r}, "
+                        "max_natr_multiplier_fraction=0.0125), using (0.0095, 0.0125)",
+                    ],
+                    sink,
+                )
+
+    def test_reversal_confirmation_upper_bound_is_capped_at_one(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_reversal_confirmation_config({"max_natr_multiplier_fraction": 1}, logger)
+        self.assertEqual(1.0, resolved["max_natr_multiplier_fraction"])
+        self.assertEqual([], sink)
+
+        for rejected in (1.0001, 1.5):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_reversal_confirmation_config(
+                        {"max_natr_multiplier_fraction": rejected}, logger
+                    )
+                self.assertEqual(0.0125, resolved["max_natr_multiplier_fraction"])
+                self.assertEqual(
+                    [
+                        f"Invalid max_natr_multiplier_fraction {rejected!r}: "
+                        f"must be finite non-negative numeric <= 1, using default 0.0125",
+                        "Invalid natr_multiplier_fraction range "
+                        "(min_natr_multiplier_fraction=0.0095, "
+                        f"max_natr_multiplier_fraction={rejected!r}), using (0.0095, 0.0125)",
+                    ],
+                    sink,
+                )
+
+    def test_reversal_confirmation_returns_a_fresh_dict_each_call(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            first = get_reversal_confirmation_config({}, logger)
+            second = get_reversal_confirmation_config({}, logger)
+        self.assertEqual([], sink)
+        first["decay_fraction"] = 0.99
+        self.assertIsNot(first, second)
+        self.assertEqual(0.5, second["decay_fraction"])
+        self.assertEqual(0.5, DEFAULTS_REVERSAL_CONFIRMATION["decay_fraction"])
+
+    def test_causal_mode_defaults_to_true_when_the_key_is_absent(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_causal_mode({}, logger)
+        self.assertIs(True, resolved)
+        self.assertEqual([], sink)
+
+    def test_causal_mode_honours_an_explicit_boolean_including_false(self) -> None:
+        for supplied, expected in ((True, True), (False, False)):
+            with self.subTest(supplied=supplied):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_causal_mode({"causal_mode": supplied}, logger)
+                self.assertIs(expected, resolved)
+                self.assertEqual([], sink)
+
+    def test_causal_mode_falls_back_to_true_for_every_non_boolean(self) -> None:
+        for rejected in ("false", "True", 0, 1, 0.0, None, [], {}, np.True_):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_causal_mode({"causal_mode": rejected}, logger)
+                self.assertIs(True, resolved)
+                self.assertEqual(
+                    [f"Invalid causal_mode value {rejected!r}: must be bool, using True"], sink
+                )
+
+    def test_causal_mode_falsy_non_boolean_does_not_disable_the_purge(self) -> None:
+        for rejected in ("", 0, 0.0, [], {}, None):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    self.assertIs(True, get_causal_mode({"causal_mode": rejected}, logger))
+                self.assertEqual(1, len(sink))
+
+    def test_label_horizon_defaults_to_one_for_an_empty_config(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_label_horizon_candles({}, logger)
+        self.assertEqual(1, resolved)
+        self.assertIsInstance(resolved, int)
+        self.assertEqual([], sink)
+
+    def test_label_horizon_uses_its_own_value_when_a_positive_int(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_label_horizon_candles(
+                {"label_period_candles": 12, "label_horizon_candles": 5}, logger
+            )
+        self.assertEqual(5, resolved)
+        self.assertIsInstance(resolved, int)
+        self.assertEqual([], sink)
+
+    def test_label_horizon_falls_back_to_label_period_candles(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            resolved = get_label_horizon_candles({"label_period_candles": 12}, logger)
+        self.assertEqual(12, resolved)
+        self.assertEqual([], sink)
+
+    def test_label_period_candles_is_clamped_to_at_least_one(self) -> None:
+        for rejected in (0, -3, "12", 12.0, True, None, [], np.int64(0)):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_label_horizon_candles({"label_period_candles": rejected}, logger)
+                self.assertEqual(1, resolved)
+                self.assertEqual([], sink)
+
+    def test_label_horizon_invalid_value_warns_and_uses_the_label_period(self) -> None:
+        for rejected in (0, -1, 5.0, True, "5", None, []):
+            with self.subTest(rejected=rejected):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_label_horizon_candles(
+                        {"label_period_candles": 12, "label_horizon_candles": rejected}, logger
+                    )
+                self.assertEqual(12, resolved)
+                self.assertEqual(
+                    [
+                        f"Invalid label_horizon_candles value {rejected!r}: "
+                        f"must be int >= 1, using 12"
+                    ],
+                    sink,
+                )
+
+    def test_label_horizon_both_invalid_yields_one_and_names_the_fallback(self) -> None:
+        for config in (
+            {"label_period_candles": 0, "label_horizon_candles": -1},
+            {"label_period_candles": 0, "label_horizon_candles": 0},
+        ):
+            with self.subTest(config=config):
+                with recorded_warnings() as (logger, sink):
+                    resolved = get_label_horizon_candles(config, logger)
+                self.assertEqual(1, resolved)
+                self.assertEqual(
+                    [
+                        f"Invalid label_horizon_candles value "
+                        f"{config['label_horizon_candles']!r}: must be int >= 1, using 1"
+                    ],
+                    sink,
+                )
+
+    def test_label_horizon_accepts_a_numpy_integer_period(self) -> None:
+        with recorded_warnings() as (logger, sink):
+            self.assertEqual(
+                9, get_label_horizon_candles({"label_period_candles": np.int64(9)}, logger)
+            )
+            self.assertEqual(
+                7,
+                get_label_horizon_candles(
+                    {"label_period_candles": np.int64(9), "label_horizon_candles": np.int64(7)},
+                    logger,
+                ),
+            )
+        self.assertEqual([], sink)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_formatting.py b/quickadapter/tests/test_utils_formatting.py
new file mode 100644 (file)
index 0000000..a75b981
--- /dev/null
@@ -0,0 +1,405 @@
+"""Log formatting of scalars and structures; requires the Freqtrade QA image."""
+
+import os
+import subprocess
+import sys
+import unittest
+
+import numpy as np
+from qa_support import QaTestCase
+from Utils import (
+    _format_value,
+    _FormatContext,
+    format_dict,
+    format_number,
+)
+
+
+def _ctx(quote_strings: bool = True, sig_digits: int = 5) -> _FormatContext:
+    return _FormatContext(quote_strings=quote_strings, sig_digits=sig_digits)
+
+
+class UtilsFormattingTest(QaTestCase):
+    def test_non_finite_scalars_render_as_symbols(self):
+        for value, expected in (
+            (float("inf"), "+∞"),
+            (float("-inf"), "-∞"),
+            (float("nan"), "NaN"),
+            (np.float64("inf"), "+∞"),
+            (np.float32("-inf"), "-∞"),
+            (np.float32("nan"), "NaN"),
+        ):
+            with self.subTest(value=repr(value)):
+                self.assertEqual(expected, format_number(value))
+        self.assertEqual(
+            "{p: +∞, n: -∞, x: NaN}",
+            format_dict({"p": float("inf"), "n": float("-inf"), "x": float("nan")}),
+        )
+
+    def test_zero_renders_without_a_decimal_point(self):
+        for value in (0, 0.0, -0.0, np.float64(0.0), np.int64(0)):
+            with self.subTest(value=repr(value)):
+                self.assertEqual("0", format_number(value))
+        self.assertEqual("{a: 0, b: 0}", format_dict({"a": 0, "b": -0.0}))
+
+    def test_scientific_notation_is_used_outside_the_fixed_range(self):
+        # The bounds are inclusive on the outside: |v| >= 1e12 and 0 < |v| <= 1e-6 switch over,
+        # so 1e-6 is scientific while the next float above it is fixed.
+        scientific = (1e-6, 9.99e-7, 1e-7, 5e-324, 1e12, 1.5e12, 1e20, -1e-6, -1e12, -5e-324)
+        fixed = (1.0000001e-6, 1e-5, 0.1, 1.5, 12345.6789, 1e11, 999_999_999_999.0, -1e12 + 1.0)
+        for value in scientific:
+            with self.subTest(value=repr(value), expected="scientific"):
+                self.assertIn("e", format_number(value))
+        for value in fixed:
+            with self.subTest(value=repr(value), expected="fixed"):
+                self.assertNotIn("e", format_number(value))
+
+    def test_fixed_notation_strips_trailing_zeros(self):
+        for value, expected in (
+            (2.0, "2"),
+            (1.0, "1"),
+            (100.0, "100"),
+            (1.5, "1.5"),
+            (0.1, "0.1"),
+            (-1.5, "-1.5"),
+            (3.14159265, "3.1416"),
+            (1.23456789, "1.2346"),
+        ):
+            with self.subTest(value=repr(value)):
+                self.assertEqual(expected, format_number(value))
+        # A value whose trailing digits round to zero loses them and its decimal point.
+        for value, expected in ((2.50, "2.5"), (1.10, "1.1"), (0.10, "0.1"), (123.0, "123")):
+            with self.subTest(value=repr(value)):
+                self.assertEqual(expected, format_number(value))
+
+    def test_the_fixed_branch_counts_significant_digits_not_decimal_places(self):
+        # The decimal places come from the magnitude, so the same request keeps the digit count
+        # as the value grows: five significant digits, whether or not a decimal point appears.
+        for value, expected in (
+            (1.23456789, "1.2346"),
+            (12.3456789, "12.346"),
+            (123.456789, "123.46"),
+            (1234.56789, "1234.6"),
+            (12345.6789, "12346"),
+            (-12345.6789, "-12346"),
+            (9999.99, "10000"),
+        ):
+            with self.subTest(value=repr(value)):
+                self.assertEqual(expected, format_number(value))
+        # Past five digits the precision goes negative and the value rounds to a whole number
+        # carrying more digits than were requested.
+        for value, expected in ((99999.9, "100000"), (123456.0, "123460"), (1234567.0, "1234600")):
+            with self.subTest(value=repr(value)):
+                self.assertEqual(expected, format_number(value))
+
+    def test_significant_digits_scale_the_output(self):
+        for digits, expected in ((2, "3.1"), (3, "3.14"), (5, "3.1416"), (8, "3.1415927")):
+            with self.subTest(digits=digits):
+                self.assertEqual(expected, format_number(3.14159265358979, digits))
+                # One digit fewer than the request goes to the exponent in the scientific branch.
+                self.assertEqual(f"{1e-7:.{digits - 1}e}", format_number(1e-7, digits))
+
+    def test_an_int_wider_than_a_numpy_integer_is_converted_before_formatting(self):
+        # format_number hands its argument to numpy, which cannot classify a Python int beyond
+        # 64 bits; the int registration converts to float first, so the structure still renders.
+        self.assertEqual("{z: 1.1806e+21}", format_dict({"z": 2**70}))
+
+    def test_significant_digits_reach_the_nested_values(self):
+        for digits, expected in (
+            (2, "{o: {y: 3.1}}"),
+            (3, "{o: {y: 3.14}}"),
+            (5, "{o: {y: 3.1416}}"),
+        ):
+            with self.subTest(digits=digits):
+                self.assertEqual(
+                    expected, format_dict({"o": {"y": 3.14159265358979}}, significant_digits=digits)
+                )
+        # The request also reaches a sequence element and the scientific branch below it.
+        self.assertEqual(
+            "{o: [3.14]}", format_dict({"o": [3.14159265358979]}, significant_digits=3)
+        )
+        self.assertEqual(
+            "{o: {y: 1.00e-07}}", format_dict({"o": {"y": 1e-7}}, significant_digits=3)
+        )
+
+    def test_a_non_numeric_value_falls_back_to_its_str(self):
+        self.assertEqual("abc", format_number("abc"))
+        self.assertEqual("1.5", format_number(np.float64(1.5)))
+        self.assertEqual("7", format_number(np.int64(7)))
+
+    def test_bools_render_as_words_in_a_structure_and_as_digits_alone(self):
+        # bool is an int subclass, so the singledispatch registration is what keeps "True" out of
+        # format_number, which would render it as "1".
+        ctx = _ctx()
+        for value in (True, False, np.bool_(True), np.bool_(False)):
+            with self.subTest(value=repr(value)):
+                self.assertEqual(str(bool(value)), _format_value(value, ctx, 0))
+        self.assertEqual("1", format_number(True))
+        self.assertEqual("0", format_number(False))
+        self.assertEqual("{a: True, b: False}", format_dict({"a": True, "b": np.bool_(False)}))
+
+    def test_an_empty_mapping_renders_as_the_empty_brace_form_or_an_empty_string(self):
+        self.assertEqual("{}", format_dict({}))
+        self.assertEqual("", format_dict({}, style="params"))
+
+    def test_the_dict_style_quotes_strings_and_the_params_style_does_not(self):
+        payload = {
+            "a": 1,
+            "b": "txt",
+            "c": 1.5,
+            "d": True,
+            "e": None,
+            "f": [1, 2],
+            "g": (3,),
+            "h": {"k": 2},
+        }
+        self.assertEqual(
+            "{a: 1, b: 'txt', c: 1.5, d: True, e: None, f: [1, 2], g: (3,), h: {k: 2}}",
+            format_dict(payload),
+        )
+        self.assertEqual(
+            "a=1, b=txt, c=1.5, d=True, e=None, f=[1, 2], g=(3,), h={k=2}",
+            format_dict(payload, style="params"),
+        )
+
+    def test_keys_are_never_quoted_or_escaped(self):
+        # Only values go through _format_value, in either style and at any nesting depth.
+        self.assertEqual(
+            "{a b: 1, with'quote: 2, with=eq: 3}",
+            format_dict({"a b": 1, "with'quote": 2, "with=eq": 3}),
+        )
+        self.assertEqual("{a: {b'c: 1}}", format_dict({"a": {"b'c": 1}}))
+        self.assertEqual("k={a b=1}", format_dict({"k": {"a b": 1}}, style="params"))
+
+    def test_a_self_referential_mapping_terminates_as_circular(self):
+        root = {"name": "root"}
+        root["self"] = root
+        self.assertEqual(
+            "{name: 'root', self: {name: 'root', self: {<circular>}}}", format_dict(root)
+        )
+        # Wrapped one level deeper the same cycle is found one level earlier in the expansion.
+        self.assertEqual("{root: {name: 'root', self: {<circular>}}}", format_dict({"root": root}))
+
+    def test_a_self_referential_sequence_terminates_as_circular(self):
+        cyclic = [1]
+        cyclic.append(cyclic)
+        self.assertEqual("{l: [1, [<circular>]]}", format_dict({"l": cyclic}))
+
+        holder = [1]
+        pair = (holder,)
+        holder.append(pair)
+        self.assertEqual("{l: [1, ([<circular>],)]}", format_dict({"l": holder}))
+
+        mapping = {"a": {}}
+        mapping["a"]["back"] = mapping
+        self.assertEqual("{a: {back: {a: {<circular>}}}}", format_dict(mapping))
+
+    def test_the_seen_set_tracks_the_current_path_and_not_every_visited_object(self):
+        # ctx.seen is added on the way down and discarded on the way back up, so a repeated
+        # sibling reference is rendered in full twice while an ancestor reference is not.
+        shared = {"k": 1}
+        self.assertEqual("{a: {k: 1}, b: {k: 1}}", format_dict({"a": shared, "b": shared}))
+        # The same holds for the sequence path, which keeps its own add and discard.
+        shared_list = [1, 2]
+        self.assertEqual(
+            "{a: [1, 2], b: [1, 2]}", format_dict({"a": shared_list, "b": shared_list})
+        )
+        shared_set = {1, 2}
+        self.assertEqual("{a: {1, 2}, b: {1, 2}}", format_dict({"a": shared_set, "b": shared_set}))
+        self.assertEqual("{l: [[1, 2], [1, 2]]}", format_dict({"l": [shared_list, shared_list]}))
+        self.assertEqual(
+            "{a: [1, 2], b: {c: [1, 2]}}", format_dict({"a": shared_list, "b": {"c": shared_list}})
+        )
+        # A context reused across two renders gives the same answer both times, which is only
+        # true when the first render discarded the ids it added.
+        reused = _ctx()
+        self.assertEqual("[1, [1, 2]]", _format_value([1, shared_list], reused, 0))
+        self.assertEqual("[1, [1, 2]]", _format_value([1, shared_list], reused, 0))
+        self.assertEqual(set(), reused.seen)
+        # Identity, not equality, is what seen tracks: two distinct objects with equal contents
+        # render exactly as the single repeated object does.
+        self.assertEqual(
+            format_dict({"a": shared, "b": shared}), format_dict({"a": {"k": 1}, "b": {"k": 1}})
+        )
+        # A chain of distinct objects terminates on the depth cap, not on identity.
+        chain = current = {}
+        for index in range(6):
+            current["next"] = {"i": index}
+            current = current["next"]
+        self.assertEqual("{root: {next: {i: 0, next: {...}}}}", format_dict({"root": chain}))
+
+    def test_nesting_is_capped_at_depth_two(self):
+        self.assertEqual("{a: {b: {c: {...}}}}", format_dict({"a": {"b": {"c": {"d": 1}}}}))
+        self.assertEqual("{a: [[[...]]]}", format_dict({"a": [[["x"]]]}))
+        self.assertEqual("{a: (((...),),)}", format_dict({"a": (((1,),),)}))
+        # A mapping hits the cap before its emptiness check, a sequence after it, so an empty
+        # mapping is elided at depth two where an empty sequence is still spelled out.
+        self.assertEqual(
+            "{a: {b: [[], {...}, (), set()]}}", format_dict({"a": {"b": [[], {}, (), set()]}})
+        )
+        for depth in range(2, 5):
+            with self.subTest(depth=depth):
+                self.assertEqual("{...}", _format_value({"b": 1}, _ctx(), depth))
+                self.assertEqual("[...]", _format_value([1], _ctx(), depth))
+                self.assertEqual("(...)", _format_value((1,), _ctx(), depth))
+
+    def test_collections_are_capped_at_ten_items_with_an_overflow_count(self):
+        payload = {
+            "list": list(range(12)),
+            "tuple": tuple(range(12)),
+            "set": set(range(12)),
+            "dict": {f"k{i}": i for i in range(12)},
+        }
+        for kind, rendered in (
+            ("list", "{list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+2]}"),
+            ("tuple", "{tuple: (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+2)}"),
+            (
+                "dict",
+                "{dict: {k0: 0, k1: 1, k2: 2, k3: 3, k4: 4, k5: 5, k6: 6, k7: 7, k8: 8, k9: 9, ...+2}}",
+            ),
+        ):
+            with self.subTest(kind=kind):
+                self.assertEqual(rendered, format_dict({kind: payload[kind]}))
+        # The count is the overflow, not the total, and a full set of exactly ten items has none.
+        self.assertEqual(
+            "{s: {0, 1, 10, 2, 3, 4, 5, 6, 7, 8, ...+1}}", format_dict({"s": set(range(11))})
+        )
+        self.assertEqual("{l: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}", format_dict({"l": list(range(10))}))
+        self.assertEqual(
+            "{l: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+1]}", format_dict({"l": list(range(11))})
+        )
+
+    def test_the_mapping_passed_to_format_dict_is_rendered_in_full(self):
+        # format_dict joins d.items() itself, so the ten-item cap reaches only nested mappings.
+        keys = [f"k{i}" for i in range(25)]
+        self.assertEqual(25, len(format_dict(dict.fromkeys(keys, 1)).split(", ")))
+        capped = format_dict({"k": list(range(15))})
+        self.assertEqual("{k: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+5]}", capped)
+        self.assertEqual(10, len(capped.split(", ")) - 1)
+
+    def test_a_set_renders_its_items_in_string_order(self):
+        words = {"delta", "alpha", "charlie", "bravo", "echo", "foxtrot", "golf", "hotel", "india"}
+        self.assertEqual(
+            "{s: {'alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india'}}",
+            format_dict({"s": words}),
+        )
+        # String order, not numeric order: "10" precedes "2".
+        self.assertEqual(
+            "{s: {10, 2, 33, 4, 55, 6, 77, 8, 99}}",
+            format_dict({"s": {10, 2, 33, 4, 55, 6, 77, 8, 99}}),
+        )
+        self.assertEqual(
+            "{s: {(3, 4), 1, 2.5, 'a', 'b'}}", format_dict({"s": {"b", 1, "a", 2.5, (3, 4)}})
+        )
+        # The cap keeps the first ten in that order, which is what makes the overflow count stable.
+        twelve = {f"s{i}" for i in range(12)}
+        expected = ["s0", "s1", "s10", "s11", "s2", "s3", "s4", "s5", "s6", "s7"]
+        self.assertEqual(
+            "{s: {" + ", ".join(f"'{item}'" for item in expected) + ", ...+2}}",
+            format_dict({"s": twelve}),
+        )
+        self.assertEqual(expected, sorted(twelve, key=str)[:10])
+
+    def test_backslashes_and_control_characters_are_escaped(self):
+        raw = "a\\b\nc\rd\te"
+        quoted = format_dict({"s": raw})
+        self.assertEqual(r"{s: 'a\\b\nc\rd\te'}", quoted)
+        self.assertEqual(r"s=a\\b\nc\rd\te", format_dict({"s": raw}, style="params"))
+        for control in ("\n", "\r", "\t"):
+            with self.subTest(control=repr(control)):
+                self.assertNotIn(control, format_dict({"s": f"x{control}y"}))
+                self.assertEqual(1, format_dict({"s": f"x{control}y"}).count("\\"))
+
+    def test_a_single_quote_is_escaped_only_where_strings_are_quoted(self):
+        # The dict style wraps in quotes and so escapes the inner ones; params leaves both bare.
+        self.assertEqual(r"{s: 'it\'s a \'test\''}", format_dict({"s": "it's a 'test'"}))
+        self.assertEqual(r"{o: {s: 'it\'s'}}", format_dict({"o": {"s": "it's"}}))
+        self.assertEqual("s=it's a 'test'", format_dict({"s": "it's a 'test'"}, style="params"))
+
+    def test_long_strings_are_truncated_after_escaping(self):
+        self.assertEqual("{s: '" + "x" * 50 + "'}", format_dict({"s": "x" * 50}))
+        self.assertEqual("{s: '" + "x" * 50 + "...'}", format_dict({"s": "x" * 51}))
+        # Escaping runs first, so fifty backslashes are a hundred characters and are cut.
+        self.assertEqual("{s: '" + "\\" * 50 + "...'}", format_dict({"s": "\\" * 50}))
+        # Quote escaping runs last, so a truncated string of quotes loses its tail to the cap.
+        self.assertEqual("{s: '" + "\\'" * 50 + "...'}", format_dict({"s": "'" * 60}))
+
+    def test_a_single_element_tuple_keeps_its_trailing_comma(self):
+        self.assertEqual("{t: (1,), l: [1], s: {1}}", format_dict({"t": (1,), "l": [1], "s": {1}}))
+        self.assertEqual("{t: 1}", format_dict({"t": 1}))
+        self.assertEqual("{t: (1, 2)}", format_dict({"t": (1, 2)}))
+
+    def test_an_unregistered_type_falls_back_to_its_repr(self):
+        class Unregistered:
+            def __repr__(self) -> str:
+                return "<unregistered>"
+
+            def __str__(self) -> str:
+                return "<str form that repr must win over>"
+
+        # repr, not str: the two disagree here so a str fallback would render the other form.
+        self.assertEqual("{a: <unregistered>}", format_dict({"a": Unregistered()}))
+        self.assertEqual(
+            "{a: frozenset({1}), b: bytearray(b'x')}",
+            format_dict({"a": frozenset({1}), "b": bytearray(b"x")}),
+        )
+
+    def test_an_array_renders_as_its_shape(self):
+        self.assertEqual(
+            "{a: array(3, 4), b: array(2,)}",
+            format_dict({"a": np.zeros((3, 4)), "b": np.array([1.0, 2.0])}),
+        )
+
+    def test_repeated_renders_of_the_same_structure_are_byte_identical(self):
+        payload = {
+            "s": {"delta", "alpha", "bravo", "echo", "foxtrot"},
+            "i": {10, 2, 33, 4, 55},
+            "l": [1.25, None, True, (2,), {"a"}],
+            "n": {"x": {"y": 2.5}},
+            "a": np.zeros((2, 2)),
+        }
+        for style in ("dict", "params"):
+            with self.subTest(style=style):
+                expected = format_dict(payload, style=style)
+                self.assertTrue(
+                    all(format_dict(payload, style=style) == expected for _ in range(100))
+                )
+        self.assertEqual("{n: {x: {y: 2.5}}}", format_dict({"n": payload["n"]}))
+
+    def test_the_output_does_not_depend_on_the_interpreter_hash_seed(self):
+        # Sets iterate in hash order, so this is the property that makes the log line stable
+        # across restarts. A subprocess is the only way to vary PYTHONHASHSEED, which is read
+        # once at interpreter start.
+        payload = {
+            "s": {
+                "delta",
+                "alpha",
+                "charlie",
+                "bravo",
+                "echo",
+                "foxtrot",
+                "golf",
+                "hotel",
+                "india",
+            },
+            "i": {10, 2, 33, 4, 55, 6, 77, 8, 99},
+            "t": {(1, 2), frozenset({3}), "z"},
+            "l": ["b", "a", "c"],
+        }
+        script = f"from Utils import format_dict\nprint(format_dict({payload!r}, style='params'))\n"
+        rendered = set()
+        for seed in ("0", "1", "12345", "999"):
+            with self.subTest(seed=seed):
+                proc = subprocess.run(
+                    [sys.executable, "-c", script],
+                    capture_output=True,
+                    text=True,
+                    check=True,
+                    env=dict(os.environ, PYTHONHASHSEED=seed),
+                )
+                rendered.add(proc.stdout.strip())
+        self.assertEqual(1, len(rendered))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_indicators.py b/quickadapter/tests/test_utils_indicators.py
new file mode 100644 (file)
index 0000000..2d8bbff
--- /dev/null
@@ -0,0 +1,762 @@
+"""Indicator kernel contracts: extrema counting, log returns, smoothing and the callback hash."""
+
+import functools
+import hashlib
+import itertools
+import unittest
+
+import numpy as np
+import pandas as pd
+from numpy.testing import assert_allclose, assert_array_equal
+from qa_support import QaTestCase, ohlcv_frame
+from Utils import (
+    MA_MODES,
+    PRICE_MODES,
+    alligator,
+    bottom_log_return,
+    calculate_min_extrema,
+    calculate_n_extrema,
+    calculate_quantile,
+    calculate_zero_lag,
+    ewo,
+    find_fractals,
+    frama,
+    get_callable_sha256,
+    get_distance,
+    get_ma_fn,
+    get_price_fn,
+    get_zl_ma_fn,
+    midpoint,
+    price_retracement_percent,
+    smma,
+    top_log_return,
+    zlema,
+)
+
+# The tolerance tiers below are the ones section 5.6 of the design fixes for these
+# quantities: midpoint and get_distance are rtol=1e-9/atol=0.0, calculate_quantile is the
+# scalars tier, and ewo(normalize=True) is percent-scaled so it takes rtol=1e-9/atol=1e-12.
+_QUANTILE_PLACES = 12
+
+
+def sawtooth(peaks: int) -> pd.Series:
+    """Return a 0/1/0 repeating series with `peaks` interior peaks, each above a zero trough."""
+    return pd.Series([float(value) for value in [0.0, 1.0, 0.0] * peaks])
+
+
+class UtilsIndicatorsTest(QaTestCase):
+    def test_the_callback_hash_is_the_digest_of_the_bytecode(self):
+        def alpha(value):
+            return value
+
+        digest = get_callable_sha256(alpha)
+        self.assertEqual(digest, hashlib.sha256(alpha.__code__.co_code).hexdigest())
+        self.assertEqual(len(digest), 64)
+        self.assertEqual(digest, digest.lower())
+        int(digest, 16)
+
+        # Determinism is asserted against a DISTINCT closure, not against a second
+        # evaluation of the same call: the hash is a pure function of the bytecode, so
+        # comparing it to itself would pass even for an implementation returning a
+        # constant digest for every callable.
+        def beta(value):
+            return value * 2
+
+        self.assertEqual(get_callable_sha256(beta), get_callable_sha256(beta))
+        self.assertNotEqual(get_callable_sha256(beta), digest)
+
+    def test_different_bytecode_hashes_differently(self):
+        # Same-shaped but structurally different bodies: arity and the constant/opcode
+        # sequence both differ, so co_code differs. Two identical bodies would collide,
+        # which is what test_strategy_sizing pins from the throttle side.
+        def unary(value):
+            return value
+
+        def binary(left, right):
+            return left + right
+
+        def increment(value):
+            return value + 1
+
+        digests = {
+            get_callable_sha256(unary),
+            get_callable_sha256(binary),
+            get_callable_sha256(increment),
+        }
+        self.assertEqual(len(digests), 3)
+
+    def test_a_bound_method_hashes_as_its_unbound_function(self):
+        class Holder:
+            def method(self):
+                return 1
+
+        holder = Holder()
+        self.assertEqual(get_callable_sha256(holder.method), get_callable_sha256(Holder.method))
+        self.assertEqual(
+            get_callable_sha256(functools.partial(holder.method)),
+            get_callable_sha256(Holder.method),
+        )
+
+    def test_a_partial_hashes_as_the_callable_it_wraps(self):
+        def gamma(value):
+            return value
+
+        self.assertEqual(
+            get_callable_sha256(functools.partial(gamma, 1)), get_callable_sha256(gamma)
+        )
+
+    def test_a_callable_object_hashes_as_its_dunder_call(self):
+        class Invoker:
+            def __call__(self):
+                return 1
+
+        self.assertEqual(get_callable_sha256(Invoker()), get_callable_sha256(Invoker.__call__))
+        self.assertEqual(
+            get_callable_sha256(functools.partial(Invoker())),
+            get_callable_sha256(Invoker.__call__),
+        )
+
+    def test_a_non_callable_is_refused(self):
+        for value in (None, 5, "not callable", [1], 1.5):
+            with (
+                self.subTest(value=repr(value)),
+                self.assertRaisesRegex(ValueError, "must be callable"),
+            ):
+                get_callable_sha256(value)
+
+    def test_a_callable_without_a_code_object_is_refused(self):
+        # throttle_callback (QuickAdapterV3.py:1397) keys every callback on this digest, so a
+        # C-level callable must be rejected rather than silently keyed on an empty string.
+        for builtin in (len, print, np.mean):
+            with (
+                self.subTest(builtin=builtin.__name__),
+                self.assertRaisesRegex(ValueError, "unable to retrieve code object"),
+            ):
+                get_callable_sha256(builtin)
+
+    def test_the_minimum_extrema_count_is_the_rounded_window_count_scaled(self):
+        # int(round(length / fit_live_predictions_candles)) * min_extrema, with the default
+        # min_extrema of 2. Values read from the implementation.
+        for length, fit_live, expected in (
+            (100, 5, 40),
+            (7, 3, 4),
+            (1000, 2, 1000),
+            (5, 5, 2),
+            (13, 7, 4),
+            (100, 3, 66),
+            (1, 1, 2),
+            (0, 5, 0),
+            (9, 4, 4),
+        ):
+            with self.subTest(length=length, fit_live=fit_live):
+                self.assertEqual(calculate_min_extrema(length, fit_live), expected)
+
+    def test_the_minimum_extrema_count_rounds_half_to_even(self):
+        # Python's round is banker's rounding, not half-up: 5/2 = 2.5 rounds down to 2 and
+        # 7/2 = 3.5 rounds up to 4. Pinned because half-up is the common assumption.
+        self.assertEqual(calculate_min_extrema(5, 2), 4)
+        self.assertEqual(calculate_min_extrema(7, 2), 8)
+
+    def test_the_minimum_extrema_count_is_a_multiple_of_the_extrema_multiplier(self):
+        for length in range(0, 40):
+            for fit_live in range(1, 9):
+                for min_extrema in (1, 2, 3, 5):
+                    with self.subTest(length=length, fit_live=fit_live, min_extrema=min_extrema):
+                        count = calculate_min_extrema(length, fit_live, min_extrema)
+                        self.assertEqual(count % min_extrema, 0)
+                        self.assertGreaterEqual(count, 0)
+
+    def test_the_extrema_count_adds_peaks_and_troughs(self):
+        # sawtooth(5) is 0,1,0,0,1,0,...: scipy finds interior peaks at 1,4,7,10,13 and
+        # interior troughs at 2,5,8,11, so 5 + 4 = 9 only if both polarities are counted.
+        series = sawtooth(5)
+        self.assertEqual(calculate_n_extrema(series), 9)
+        self.assertEqual(calculate_n_extrema(series), 2 * 5 - 1)
+
+    def test_the_extrema_count_separates_peaks_from_troughs(self):
+        # A peaks-only shape and a troughs-only shape each contribute, so the count is a sum
+        # of two independent detections rather than a doubled count of one of them.
+        for peaks in range(1, 6):
+            series = sawtooth(peaks)
+            with self.subTest(peaks=peaks):
+                expected_peaks = peaks
+                expected_troughs = peaks - 1
+                self.assertEqual(calculate_n_extrema(series), expected_peaks + expected_troughs)
+
+    def test_the_extrema_count_ignores_series_without_an_interior_turn(self):
+        for series in (
+            pd.Series([1.0, 2.0, 3.0, 4.0, 5.0]),
+            pd.Series([5.0, 4.0, 3.0, 2.0, 1.0]),
+            pd.Series([1.0] * 5),
+            pd.Series([1.0]),
+            pd.Series([], dtype=float),
+        ):
+            with self.subTest(series=series.to_numpy().tolist()):
+                self.assertEqual(calculate_n_extrema(series), 0)
+
+    def test_the_log_returns_are_the_closed_form_on_a_positive_series(self):
+        frame = ohlcv_frame([100.0, 102, 101, 103, 105, 104, 106, 108])
+        close = frame.get("close")
+        for period in (1, 2, 3, 5):
+            with self.subTest(period=period):
+                reference_top = close.rolling(period, min_periods=period).max().shift(1)
+                reference_bottom = close.rolling(period, min_periods=period).min().shift(1)
+                expected_top = np.log(close / reference_top)
+                expected_bottom = np.log(close / reference_bottom)
+                assert_allclose(
+                    top_log_return(frame, period).to_numpy(),
+                    expected_top.to_numpy(),
+                    rtol=1e-12,
+                    atol=0.0,
+                    equal_nan=True,
+                )
+                assert_allclose(
+                    bottom_log_return(frame, period).to_numpy(),
+                    expected_bottom.to_numpy(),
+                    rtol=1e-12,
+                    atol=0.0,
+                    equal_nan=True,
+                )
+
+    def test_a_non_positive_price_yields_a_missing_log_return_not_an_infinity(self):
+        # safe_log_ratio replaces a non-positive operand with the NaN fallback, so a zero or
+        # negative price leaves a hole in the series rather than emitting -inf. assertAlmostEqual
+        # cannot express this; the positions are checked with np.isnan and the no-infinity
+        # invariant separately.
+        frame = ohlcv_frame([100.0, 0.0, 102, 0.0, 103, 0.0, 105, 106, 107, 108, 109, 110])
+        for indicator in (top_log_return, bottom_log_return):
+            with self.subTest(indicator=indicator.__name__):
+                result = indicator(frame, 3).to_numpy()
+                self.assertFalse(np.isinf(result).any())
+                self.assertTrue(np.isnan(result).any())
+                close = frame.get("close").to_numpy()
+                self.assertTrue(np.isnan(result[close <= 0.0]).all())
+
+    def test_a_fully_non_positive_price_leaves_the_whole_series_missing(self):
+        for values in ([0.0] * 12, [-1.0, -2.0, -3.0, -4.0, -5.0, -6.0]):
+            frame = ohlcv_frame(values)
+            with self.subTest(values=values):
+                for indicator in (top_log_return, bottom_log_return, price_retracement_percent):
+                    result = indicator(frame, 3).to_numpy()
+                    self.assertTrue(np.isnan(result).all())
+                    self.assertFalse(np.isinf(result).any())
+
+    def test_a_period_below_one_is_refused_by_the_log_return_indicators(self):
+        frame = ohlcv_frame([100.0, 101, 102, 103])
+        for indicator in (top_log_return, bottom_log_return, price_retracement_percent):
+            for period in (0, -1):
+                with (
+                    self.subTest(indicator=indicator.__name__, period=period),
+                    self.assertRaises(ValueError),
+                ):
+                    indicator(frame, period)
+
+    def test_the_retracement_is_the_log_position_within_the_rolling_range(self):
+        closes = [100.0, 102, 101, 103, 105, 104, 106, 108]
+        frame = ohlcv_frame(closes)
+        close = frame.get("close")
+        low = close.rolling(3, min_periods=3).min().shift(1)
+        high = close.rolling(3, min_periods=3).max().shift(1)
+        expected = np.log(close / low) / np.log(high / low)
+        assert_allclose(
+            price_retracement_percent(frame, 3).to_numpy(),
+            expected.to_numpy(),
+            rtol=1e-12,
+            atol=0.0,
+            equal_nan=True,
+        )
+
+    def test_a_flat_range_retraces_to_the_bottom_rather_than_dividing_by_zero(self):
+        # A constant close makes the log(high/low) denominator exactly 0.0; the result is
+        # pinned to 0.0 instead of a division, and stays on the series index.
+        frame = ohlcv_frame([100.0] * 8)
+        result = price_retracement_percent(frame, 3)
+        self.assertTrue(result.index.equals(frame.index))
+        assert_allclose(
+            result.to_numpy()[3:],
+            np.zeros(5),
+            rtol=0.0,
+            atol=0.0,
+        )
+        self.assertFalse(np.isinf(result.to_numpy()).any())
+
+    def test_a_near_zero_range_retraces_to_the_bottom(self):
+        # np.isclose on the denominator admits any range smaller than 1e-8 in log space,
+        # so a ramp whose total range is 1e-7 still reads as flat.
+        for span in (0.0, 1e-9, 1e-7):
+            frame = ohlcv_frame([100.0 + span * step for step in range(8)])
+            with self.subTest(span=span):
+                result = price_retracement_percent(frame, 3).to_numpy()[3:]
+                assert_allclose(result, np.zeros(5), rtol=0.0, atol=0.0)
+
+    def test_an_empty_sample_has_no_quantile(self):
+        # Documented empty-input return; assertAlmostEqual(nan, nan) would fail, so the
+        # branch is asserted through np.isnan.
+        self.assertTrue(np.isnan(calculate_quantile(np.array([], dtype=float), 1.0)))
+        self.assertTrue(np.isnan(calculate_quantile(np.array([np.nan, np.nan]), 1.0)))
+
+    def test_a_quantile_is_the_share_of_the_sample_at_or_below_the_value(self):
+        sample = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
+        for value, expected in (
+            (0.0, 0.0),
+            (1.0, 0.1),
+            (2.0, 0.3),
+            (3.0, 0.5),
+            (5.0, 0.9),
+            (6.0, 1.0),
+        ):
+            with self.subTest(value=value):
+                self.assertAlmostEqual(
+                    calculate_quantile(sample, value), expected, places=_QUANTILE_PLACES
+                )
+
+    def test_a_quantile_rises_with_the_value_and_stays_within_the_unit_interval(self):
+        sample = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 3.0, 2.0, 5.0, 1.0, 4.0])
+        scores = [float(calculate_quantile(sample, value)) for value in np.linspace(-1.0, 6.0, 25)]
+        for lower, upper in itertools.pairwise(scores):
+            self.assertLessEqual(lower, upper)
+        for score in scores:
+            self.assertGreaterEqual(score, 0.0)
+            self.assertLessEqual(score, 1.0)
+
+    def test_a_nan_in_the_sample_is_omitted_from_the_quantile(self):
+        self.assertAlmostEqual(
+            float(calculate_quantile(np.array([1.0, np.nan, 3.0]), 2.0)),
+            0.5,
+            places=_QUANTILE_PLACES,
+        )
+
+    def test_a_multidimensional_sample_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "must be 1-dimensional"):
+            calculate_quantile(np.array([[1.0, 2.0], [3.0, 4.0]]), 3.0)
+
+    def test_the_fractal_lists_name_the_peak_and_trough_positions(self):
+        highs = [1.0, 2, 3, 2, 1, 2, 3, 2, 1]
+        frame = pd.DataFrame({"high": highs, "low": [value - 1.0 for value in highs]})
+        fractal_highs, fractal_lows = find_fractals(frame, 2)
+        self.assertEqual(fractal_highs, [2, 6])
+        self.assertEqual(fractal_lows, [4])
+        for position in fractal_highs + fractal_lows:
+            self.assertIn(position, frame.index.tolist())
+        self.assertFalse(set(fractal_highs) & set(fractal_lows))
+
+    def test_the_fractal_masks_span_the_whole_frame(self):
+        highs = [1.0, 2, 3, 2, 1, 2, 3, 2, 1]
+        frame = pd.DataFrame({"high": highs, "low": [value - 1.0 for value in highs]})
+        fractal_highs, fractal_lows = find_fractals(frame, 2)
+        high_mask = frame.index.isin(fractal_highs)
+        low_mask = frame.index.isin(fractal_lows)
+        assert_array_equal(
+            high_mask, np.array([False, False, True, False, False, False, True, False, False])
+        )
+        assert_array_equal(
+            low_mask, np.array([False, False, False, False, True, False, False, False, False])
+        )
+        self.assertEqual(high_mask.dtype, np.bool_)
+        self.assertEqual(high_mask.shape, (len(frame),))
+
+    def test_a_monotone_series_carries_no_fractal(self):
+        frame = pd.DataFrame(
+            {"high": [1.0, 2, 3, 4, 5, 6, 7, 8, 9], "low": [0.0, 1, 2, 3, 4, 5, 6, 7, 8]},
+        )
+        fractal_highs, fractal_lows = find_fractals(frame, 2)
+        self.assertEqual(fractal_highs, [])
+        self.assertEqual(fractal_lows, [])
+        self.assertFalse(frame.index.isin(fractal_highs + fractal_lows).any())
+
+    def test_a_series_shorter_than_two_periods_plus_one_carries_no_fractal(self):
+        highs = [1.0, 2, 3, 2, 1]
+        frame = pd.DataFrame({"high": highs, "low": [value - 1.0 for value in highs]})
+        for length in range(0, 5):
+            with self.subTest(length=length):
+                self.assertEqual(find_fractals(frame.iloc[:length], 2), ([], []))
+
+    def test_a_fractal_is_only_looked_for_inside_the_frame(self):
+        # The detector skips the first and last `period` bars, so widening the period can only
+        # remove candidates, never add them.
+        highs = [1.0, 2, 3, 2, 1, 2, 3, 2, 1]
+        frame = pd.DataFrame({"high": highs, "low": [value - 1.0 for value in highs]})
+        counts = [
+            sum(len(part) for part in find_fractals(frame, period)) for period in (1, 2, 3, 4)
+        ]
+        self.assertEqual(counts, sorted(counts, reverse=True))
+        self.assertEqual(counts[0], 3)
+        self.assertEqual(counts[-1], 0)
+
+    def test_a_tied_neighbour_is_not_a_fractal(self):
+        # The comparison against each neighbour is strict, so a plateau registers no fractal
+        # at any bar of it. In 1,2,3,3,2,1,2,3,2,1 the peak at index 2 ties with its right
+        # neighbour at index 3, so neither is a fractal and only index 7 qualifies.
+        highs = [1.0, 2, 3, 3, 2, 1, 2, 3, 2, 1]
+        frame = pd.DataFrame({"high": highs, "low": [value - 1.0 for value in highs]})
+        fractal_highs, _ = find_fractals(frame, 2)
+        self.assertEqual(fractal_highs, [7])
+        for plateau in (2, 3):
+            with self.subTest(plateau=plateau):
+                self.assertNotIn(plateau, fractal_highs)
+
+    def test_each_moving_average_name_resolves_to_its_own_callable(self):
+        callables = [get_ma_fn(name) for name in MA_MODES]
+        for name, function in zip(MA_MODES, callables, strict=True):
+            with self.subTest(name=name):
+                self.assertTrue(callable(function))
+                self.assertIs(get_ma_fn(name), function)
+        self.assertEqual(len(set(callables)), len(MA_MODES))
+        self.assertEqual(len({get_zl_ma_fn(name) for name in MA_MODES}), len(MA_MODES))
+
+    def test_an_unknown_moving_average_name_falls_back_to_the_simple_average(self):
+        # The registry is a .get with the SMA default, so an unrecognised name is not an
+        # error: it silently resolves to the first entry. Pinned because a raise here would
+        # break every caller that forwards a user-supplied mamode.
+        sma = get_ma_fn(MA_MODES[0])
+        for name in ("bogus", "", "SMA", "sma ", None, 0):
+            with self.subTest(name=repr(name)):
+                self.assertIs(get_ma_fn(name), sma)
+
+    def test_each_price_name_resolves_to_its_own_callable(self):
+        callables = [get_price_fn(name) for name in PRICE_MODES]
+        for name, function in zip(PRICE_MODES, callables, strict=True):
+            with self.subTest(name=name):
+                self.assertTrue(callable(function))
+                self.assertIs(get_price_fn(name), function)
+        self.assertEqual(len(set(callables)), len(PRICE_MODES))
+
+    def test_an_unknown_price_name_falls_back_to_the_close(self):
+        frame = ohlcv_frame([10.0, 11.0, 12.0])
+        close = frame.get("close")
+        for name in ("bogus", "", "CLOSE", None):
+            with self.subTest(name=repr(name)):
+                pd.testing.assert_series_equal(get_price_fn(name)(frame), close)
+
+    def test_a_constant_series_smooths_to_the_constant(self):
+        # zlema leads by the half-period lag it removes, smma and frama by period - 1 bars;
+        # past that warmup every bar of a constant series must read back as that constant.
+        series = pd.Series([7.0] * 20)
+        for period in (2, 4, 6, 10):
+            for indicator, warmup in ((zlema, (period - 1) // 2), (smma, period - 1)):
+                result = indicator(series, period)
+                self.assertEqual(int(result.isna().sum()), warmup)
+                assert_allclose(
+                    result.to_numpy()[warmup:], np.full(20 - warmup, 7.0), rtol=0.0, atol=0.0
+                )
+            smoothed = frama(ohlcv_frame([7.0] * 20), period)
+            self.assertEqual(int(smoothed.isna().sum()), period - 1)
+            assert_allclose(
+                smoothed.to_numpy()[period - 1 :],
+                np.full(21 - period, 7.0),
+                rtol=0.0,
+                atol=0.0,
+            )
+
+    def test_a_missing_input_value_never_produces_an_infinity(self):
+        # The gap is absorbed: ewm and the smma seed skip missing bars, so from `period` on
+        # every output is a finite number. Nothing here may become an infinity.
+        series = pd.Series([1.0, 2, np.nan, 4, 5, 6, 7, 8, 9, 10])
+        for indicator in (zlema, smma):
+            for period in (1, 2, 3, 5, 6):
+                with self.subTest(indicator=indicator.__name__, period=period):
+                    result = indicator(series, period).to_numpy()
+                    self.assertFalse(np.isinf(result).any())
+                    self.assertTrue(np.isfinite(result[period:]).all())
+
+    def test_frama_degrades_to_missing_after_a_gap_in_its_input(self):
+        # _fractal_dimension returns nan once its window contains a nan high, and the
+        # recursion skips on nan, so the tail stays missing rather than diverging: the
+        # output after the gap is all nan and never an infinity.
+        frame = ohlcv_frame([7.0] * 20)
+        frame.loc[8, "high"] = np.nan
+        result = frama(frame, 6).to_numpy()
+        self.assertFalse(np.isinf(result).any())
+        self.assertTrue(np.isnan(result[:5]).all())
+        assert_allclose(result[5:9], np.full(4, 7.0), rtol=0.0, atol=0.0)
+        self.assertTrue(np.isnan(result[9:]).all())
+
+    def test_frama_survives_an_intact_series_across_the_same_window(self):
+        # The counterpart to the gap case: without the nan the recursion runs to the end, so
+        # the missing tail above is caused by the gap and not by the period.
+        result = frama(ohlcv_frame([7.0] * 20), 6).to_numpy()
+        self.assertTrue(np.isnan(result[:5]).all())
+        assert_allclose(result[5:], np.full(15, 7.0), rtol=0.0, atol=0.0)
+
+    def test_frama_recurses_toward_close_on_a_series_with_a_real_range(self):
+        # The other frama fixtures are all CONSTANT frames, and _fractal_dimension returns
+        # 1.0 whenever either half-range is zero. So alpha collapses to exp(-4.6 * 0) == 1.0
+        # and neither the -4.6 scale, nor the `fd - 1` offset, nor the clip bounds are
+        # observable: a mutant that doubles alpha survives on all of them. Only a frame
+        # with a genuine high/low range in every window can see any of the three.
+        result = frama(ohlcv_frame([100.0 + i for i in range(30)]), 6).to_numpy()
+        self.assertTrue(np.isnan(result[:5]).all())
+        # Derivation of the tail: the window seeds from the mean of the first `period`
+        # closes (102.5) and each subsequent bar smooths toward the previous one, which on
+        # this unit ramp leaves close minus 0.5 after the first jump.
+        assert_allclose(result[5], 102.5, rtol=0.0, atol=1e-9)
+        assert_allclose(result[6], 106.0, rtol=0.0, atol=1e-9)
+        assert_allclose(result[7:], np.arange(107.0, 130.0), rtol=0.0, atol=1e-9)
+
+    def test_the_zero_lag_frama_de_lags_before_the_fractal_dimension(self):
+        # zero_lag replaces high, low and close with calculate_zero_lag first, which makes the
+        # seed the mean of the de-lagged closes rather than the raw ones. Ignoring the flag
+        # leaves a different seed, so the first finite bar alone distinguishes the two.
+        frame = ohlcv_frame([float(value) for value in range(1, 13)])
+        de_lagged = frama(frame, 4, zero_lag=True).to_numpy()
+        raw = frama(frame, 4, zero_lag=False).to_numpy()
+        self.assertFalse(np.array_equal(de_lagged, raw, equal_nan=True))
+        closes = frame.get("close")
+        self.assertAlmostEqual(
+            float(de_lagged[3]),
+            float(calculate_zero_lag(closes, 4).iloc[:4].mean()),
+            places=9,
+        )
+        self.assertAlmostEqual(float(raw[3]), float(closes.iloc[:4].mean()), places=9)
+
+    def test_a_framed_gap_stops_the_fractal_dimension_at_the_window_edge(self):
+        # _fractal_dimension returns 1.0 when either half-range is zero, which pins alpha at
+        # exp(0) = 1 and makes the recursion follow close exactly. A constant frame is the
+        # only shape that reaches that branch.
+        result = frama(ohlcv_frame([7.0] * 12), 4).to_numpy()
+        self.assertFalse(np.isinf(result).any())
+        assert_allclose(result[3:], np.full(9, 7.0), rtol=0.0, atol=0.0)
+
+    def test_the_smma_follows_its_own_recursion(self):
+        # Seeded with the mean of the first `period` bars, then alpha = 1/period per bar.
+        # Pinning the recursion catches a wrong alpha, which a constant or a NaN series
+        # cannot distinguish because both collapse to the same constant.
+        series = pd.Series([1.0, 3, 2, 5, 4, 7, 6, 9, 8, 11, 10, 13])
+        for period in (2, 3, 4, 5):
+            with self.subTest(period=period):
+                values = series.to_numpy()
+                expected = np.full(len(values), np.nan)
+                seed = float(np.mean(values[:period]))
+                expected[period - 1] = seed
+                previous = seed
+                for index in range(period, len(values)):
+                    previous += (values[index] - previous) / period
+                    expected[index] = previous
+                assert_allclose(
+                    smma(series, period).to_numpy(), expected, rtol=1e-12, atol=0.0, equal_nan=True
+                )
+
+    def test_the_zero_lag_ema_follows_its_own_recursion(self):
+        # alpha = 2/(period+1) applied to 2*series - series.shift(lag) with adjust=False, so
+        # the first defined bar seeds the recursion rather than the whole history weighting it.
+        series = pd.Series([1.0, 3, 2, 5, 4, 7, 6, 9, 8, 11, 10, 13])
+        values = series.to_numpy()
+        for period in (2, 3, 5, 7):
+            with self.subTest(period=period):
+                lag = (period - 1) // 2
+                alpha = 2 / (period + 1)
+                expected = np.full(len(values), np.nan)
+                previous = None
+                for index in range(len(values)):
+                    if index < lag:
+                        continue
+                    de_lagged = 2 * values[index] - values[index - lag]
+                    previous = (
+                        de_lagged
+                        if previous is None
+                        else alpha * de_lagged + (1 - alpha) * previous
+                    )
+                    expected[index] = previous
+                assert_allclose(
+                    zlema(series, period).to_numpy(), expected, rtol=1e-12, atol=0.0, equal_nan=True
+                )
+
+    def test_the_smma_never_looks_ahead(self):
+        # The purge depends on causality: changing a later bar must leave every earlier
+        # output bit-identical, NaN warmup included.
+        series = pd.Series([1.0, 3, 2, 5, 4, 7, 6, 9, 8, 11, 10, 13])
+        for period in (2, 3, 4, 6):
+            perturbed = series.copy()
+            perturbed.iloc[9] = 1000.0
+            for indicator in (smma, zlema):
+                with self.subTest(indicator=indicator.__name__, period=period):
+                    before = indicator(series, period).to_numpy()
+                    after = indicator(perturbed, period).to_numpy()
+                    assert_array_equal(before[:9], after[:9])
+                    self.assertEqual(before.shape, after.shape)
+                    self.assertNotEqual(before[9], after[9])
+
+    def test_the_smma_keeps_the_index_and_is_empty_when_the_series_is_shorter(self):
+        series = pd.Series([7.0] * 20)
+        result = smma(series, 25)
+        self.assertEqual(len(result), 20)
+        self.assertTrue(result.index.equals(series.index))
+        self.assertTrue(result.isna().all())
+
+    def test_the_smma_shifts_by_its_offset(self):
+        series = pd.Series([1.0, 2, 3, 4, 5, 6, 7, 8])
+        shifted = smma(series, 3, offset=2)
+        assert_array_equal(shifted.to_numpy()[2:], smma(series, 3).to_numpy()[:-2])
+
+    def test_a_non_positive_smma_period_is_refused(self):
+        series = pd.Series([1.0, 2, 3])
+        for period in (0, -3):
+            with self.subTest(period=period), self.assertRaisesRegex(ValueError, "must be > 0"):
+                smma(series, period)
+
+    def test_an_odd_fractal_dimension_period_is_refused(self):
+        frame = ohlcv_frame([1.0, 2, 3, 4, 5, 6])
+        for period in (3, 5, 7):
+            with self.subTest(period=period), self.assertRaisesRegex(ValueError, "must be even"):
+                frama(frame, period)
+
+    def test_the_alligator_lines_are_the_shifted_smoothed_averages_of_the_median_price(self):
+        # The multipliers are asymmetric so that median, close, average, typical and
+        # weighted-close are five different series: a symmetric frame collapses them all
+        # onto the close and cannot tell the pricemode argument from being ignored.
+        close = pd.Series(np.linspace(10.0, 40.0, 30))
+        frame = pd.DataFrame(
+            {"open": close, "high": close * 1.5, "low": close * 0.8, "close": close},
+        )
+        for pricemode in PRICE_MODES:
+            prices = get_price_fn(pricemode)(frame)
+            with self.subTest(pricemode=pricemode):
+                jaw, teeth, lips = alligator(frame, 6, 4, 3, 2, 1, 1, pricemode=pricemode)
+                for line, period, offset in ((jaw, 6, 2), (teeth, 4, 1), (lips, 3, 1)):
+                    assert_array_equal(
+                        line.to_numpy(), smma(prices, period, offset=offset).to_numpy()
+                    )
+
+    def test_the_alligator_lines_are_de_lagged_before_they_are_smoothed(self):
+        # zero_lag reaches smma, so each line smooths calculate_zero_lag(prices, period) with
+        # its own period rather than the shared one.
+        close = pd.Series(np.linspace(10.0, 40.0, 30))
+        frame = pd.DataFrame(
+            {"open": close, "high": close * 1.5, "low": close * 0.8, "close": close},
+        )
+        median = (frame.get("high") + frame.get("low")) / 2
+        causal = alligator(frame, 6, 4, 3, 2, 1, 1, pricemode="median")
+        de_lagged = alligator(frame, 6, 4, 3, 2, 1, 1, pricemode="median", zero_lag=True)
+        for line, causal_line, period, offset in zip(
+            de_lagged, causal, (6, 4, 3), (2, 1, 1), strict=True
+        ):
+            with self.subTest(period=period):
+                assert_array_equal(
+                    line.to_numpy(),
+                    smma(calculate_zero_lag(median, period), period, offset=offset).to_numpy(),
+                )
+                self.assertFalse(
+                    np.array_equal(line.to_numpy(), causal_line.to_numpy(), equal_nan=True)
+                )
+
+    def test_the_oscillator_is_the_gap_between_its_two_averages(self):
+        frame = ohlcv_frame([100.0, 101, 102, 101.5, 103, 104, 103.5, 105, 106, 105.5, 107, 108])
+        close = frame.get("close")
+        for mamode in MA_MODES:
+            for ma1_length, ma2_length in ((3, 6), (2, 8)):
+                with self.subTest(mamode=mamode, ma1=ma1_length, ma2=ma2_length):
+                    average = get_ma_fn(mamode)
+                    expected = average(close, ma1_length) - average(close, ma2_length)
+                    assert_allclose(
+                        ewo(frame, ma1_length, ma2_length, mamode=mamode),
+                        expected,
+                        rtol=1e-12,
+                        atol=0.0,
+                        equal_nan=True,
+                    )
+
+    def test_the_oscillator_waits_for_its_slowest_average(self):
+        # The longer period dominates the warmup: the gap is missing for exactly as many bars
+        # as the slow average needs, so a shallow crossover cannot appear before the data
+        # supports it.
+        frame = ohlcv_frame([100.0, 101, 102, 101.5, 103, 104, 103.5, 105, 106, 105.5, 107, 108])
+        close = frame.get("close")
+        for ma1_length, ma2_length in ((2, 6), (3, 8), (4, 10)):
+            with self.subTest(ma1=ma1_length, ma2=ma2_length):
+                raw = ewo(frame, ma1_length, ma2_length)
+                slow = get_ma_fn(MA_MODES[0])(close, ma2_length)
+                assert_array_equal(np.isnan(raw), np.isnan(slow))
+                self.assertFalse(np.isinf(raw).any())
+
+    def test_a_normalised_oscillator_is_the_raw_gap_as_a_percent_of_price(self):
+        frame = ohlcv_frame([100.0, 101, 102, 101.5, 103, 104, 103.5, 105, 106, 105.5, 107, 108])
+        raw = ewo(frame, 3, 6)
+        close = frame.get("close").to_numpy()
+        assert_allclose(
+            ewo(frame, 3, 6, normalize=True),
+            raw / close * 100.0,
+            rtol=1e-9,
+            atol=1e-12,
+            equal_nan=True,
+        )
+
+    def test_the_zero_lag_oscillator_is_not_the_causal_one(self):
+        frame = ohlcv_frame([100.0, 101, 102, 101.5, 103, 104, 103.5, 105, 106, 105.5, 107, 108])
+        causal = ewo(frame, 3, 6)
+        differs = False
+        for mamode in MA_MODES:
+            with self.subTest(mamode=mamode):
+                zero_lagged = ewo(frame, 3, 6, zero_lag=True, mamode=mamode)
+                self.assertEqual(zero_lagged.shape, causal.shape)
+                self.assertFalse(np.isinf(zero_lagged).any())
+                differs |= not np.array_equal(zero_lagged, causal, equal_nan=True)
+        self.assertTrue(differs)
+
+    def test_the_zero_lag_oscillator_averages_the_de_lagged_series(self):
+        # ma_fn becomes get_zl_ma_fn(mamode), i.e. the registry average applied to the
+        # zero-lagged input. The expectation is built from get_ma_fn and calculate_zero_lag
+        # separately, so a get_zl_ma_fn that forwarded the raw series would not reproduce it.
+        # The ema name is excluded because zero_lag substitutes zlema for it outright.
+        frame = ohlcv_frame([100.0, 101, 102, 101.5, 103, 104, 103.5, 105, 106, 105.5, 107, 108])
+        close = frame.get("close")
+        registry_modes = tuple(name for name in MA_MODES if name != MA_MODES[1])
+        for mamode in registry_modes:
+            for ma1_length, ma2_length in ((3, 6), (4, 9)):
+                with self.subTest(mamode=mamode, ma1=ma1_length, ma2=ma2_length):
+                    average = get_ma_fn(mamode)
+                    fast = average(calculate_zero_lag(close, ma1_length), timeperiod=ma1_length)
+                    slow = average(calculate_zero_lag(close, ma2_length), timeperiod=ma2_length)
+                    assert_allclose(
+                        ewo(frame, ma1_length, ma2_length, zero_lag=True, mamode=mamode),
+                        fast - slow,
+                        rtol=1e-12,
+                        atol=0.0,
+                        equal_nan=True,
+                    )
+
+    def test_the_zero_lag_ema_oscillator_is_the_zero_lag_ema_gap(self):
+        # The ema branch of zero_lag substitutes zlema itself rather than going through
+        # get_zl_ma_fn, so it leads the shared registry path.
+        frame = ohlcv_frame([100.0, 101, 102, 101.5, 103, 104, 103.5, 105, 106, 105.5, 107, 108])
+        close = frame.get("close")
+        expected = zlema(close, 3).to_numpy() - zlema(close, 6).to_numpy()
+        assert_allclose(
+            ewo(frame, 3, 6, zero_lag=True, mamode=MA_MODES[1]),
+            expected,
+            rtol=1e-12,
+            atol=0.0,
+            equal_nan=True,
+        )
+
+    def test_the_oscillator_of_a_constant_series_collapses_to_zero(self):
+        frame = ohlcv_frame([7.0] * 20)
+        for mamode in MA_MODES:
+            with self.subTest(mamode=mamode):
+                raw = ewo(frame, 3, 6, mamode=mamode)
+                self.assertFalse(np.isinf(raw).any())
+                assert_allclose(
+                    raw[np.isfinite(raw)],
+                    np.zeros(int(np.isfinite(raw).sum())),
+                    rtol=0.0,
+                    atol=0.0,
+                )
+
+    def test_the_distance_is_the_absolute_gap_and_the_midpoint_brackets_it(self):
+        self.assertEqual(get_distance(-3.5, 1.25), 4.75)
+        self.assertEqual(get_distance(2.5, 2.5), 0.0)
+        assert_allclose(midpoint(0.1, 0.3), 0.2, rtol=1e-9, atol=0.0)
+        left = pd.Series([1.0, 4.0])
+        right = pd.Series([3.0, 2.0])
+        assert_allclose(
+            get_distance(left, right).to_numpy(), np.array([2.0, 2.0]), rtol=1e-9, atol=0.0
+        )
+        assert_allclose(midpoint(left, right).to_numpy(), np.array([2.0, 3.0]), rtol=1e-9, atol=0.0)
+        # The TypeVar admits pd.Series or float, and Python's true division makes an int pair
+        # come back as a float: two of the four production call sites get that float back.
+        self.assertIsInstance(midpoint(2, 3), float)
+        assert_allclose(midpoint(2, 3), 2.5, rtol=1e-9, atol=0.0)
+        self.assertIsInstance(midpoint(left, right), pd.Series)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_label_weights.py b/quickadapter/tests/test_utils_label_weights.py
new file mode 100644 (file)
index 0000000..93ecc59
--- /dev/null
@@ -0,0 +1,224 @@
+"""Sample weight composition and support contracts; requires the Freqtrade QA image."""
+
+import logging
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import QaTestCase
+from Utils import (
+    LabelWeightSupportError,
+    _effective_sample_size,
+    compose_sample_weights,
+    nan_average,
+    non_zero_diff,
+    sanitize_and_renormalize,
+    summarize_label_weight_support,
+)
+
+LOGGER = logging.getLogger("test-label-weights")
+CONTEXT = "[BTC/USDT] test"
+
+
+class SanitizeAndRenormalizeTest(QaTestCase):
+    def _sanitize(self, values, drop_mask=None):
+        return sanitize_and_renormalize(values, drop_mask, logger=LOGGER, context=CONTEXT)
+
+    def test_the_output_always_has_unit_mean(self):
+        for values in ([1.0, 1.0, 1.0], [0.1, 0.9, 5.0], [3.0, 1.0], [1e-6, 1e6]):
+            with self.subTest(values=values):
+                self.assertAlmostEqual(float(np.mean(self._sanitize(np.array(values)))), 1.0)
+
+    def test_non_finite_and_non_positive_entries_become_exactly_zero(self):
+        result = self._sanitize(np.array([1.0, np.nan, np.inf, -np.inf, 0.0, -2.0, 3.0]))
+        np.testing.assert_array_equal(result[[1, 2, 3, 4, 5]], np.zeros(5))
+
+    def test_the_surviving_entries_keep_their_relative_proportions(self):
+        result = self._sanitize(np.array([1.0, 2.0, 3.0]))
+        self.assertAlmostEqual(float(result[1] / result[0]), 2.0)
+        self.assertAlmostEqual(float(result[2] / result[0]), 3.0)
+
+    def test_a_dropped_row_is_exactly_zero(self):
+        result = self._sanitize(np.array([1.0, 2.0, 3.0]), np.array([False, True, False]))
+        self.assertEqual(result[1], 0.0)
+        self.assertGreater(result[0], 0.0)
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+    def test_a_full_collapse_falls_back_to_uniform_weights_on_survivors(self):
+        # The fallback is rescaled over ALL n rows, dropped ones included, so the mean stays
+        # 1 and the surviving row carries the full mass: 3 here, not 1.
+        result = self._sanitize(np.array([0.0, 0.0, 0.0]), np.array([True, False, True]))
+        np.testing.assert_allclose(result, np.array([0.0, 3.0, 0.0]), rtol=1e-12, atol=0.0)
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+    def test_a_collapse_with_no_survivor_ignores_the_mask_to_preserve_unit_mean(self):
+        result = self._sanitize(np.array([0.0, 0.0]), np.array([True, True]))
+        np.testing.assert_allclose(result, np.ones(2), rtol=1e-12, atol=0.0)
+
+    def test_a_mismatched_drop_mask_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "drop_mask shape"):
+            self._sanitize(np.array([1.0, 1.0]), np.array([True]))
+
+    def test_a_non_boolean_drop_mask_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "not boolean"):
+            self._sanitize(np.array([1.0, 1.0]), np.array([1, 0]))
+
+    def test_an_empty_input_stays_empty(self):
+        self.assertEqual(self._sanitize(np.array([])).size, 0)
+
+
+class EffectiveSampleSizeTest(QaTestCase):
+    def test_uniform_weights_give_the_full_sample_size(self):
+        self.assertAlmostEqual(_effective_sample_size(np.ones(10)), 10.0)
+
+    def test_ess_follows_kishs_formula(self):
+        weights = np.array([1.0, 1.0, 2.0, 2.0])
+        expected = float(weights.sum() ** 2 / np.square(weights).sum())
+        self.assertAlmostEqual(_effective_sample_size(weights), expected, places=10)
+
+    def test_ess_never_exceeds_the_number_of_positive_entries(self):
+        weights = np.array([1.0, 0.01, 0.01, 0.01, 5.0])
+        positive = int((weights > 0).sum())
+        self.assertLessEqual(_effective_sample_size(weights), positive)
+
+    def test_degenerate_input_yields_zero(self):
+        for weights in (
+            np.array([]),
+            np.zeros(4),
+            np.array([-1.0, -2.0]),
+            np.array([np.nan, np.inf]),
+        ):
+            with self.subTest(weights=weights.tolist()):
+                self.assertEqual(_effective_sample_size(weights), 0.0)
+
+    def test_non_positive_and_non_finite_entries_leave_the_rest_intact(self):
+        # The filter keeps strictly positive finite entries only, so a mixed vector reports
+        # the ESS of its positive part rather than zero.
+        self.assertAlmostEqual(_effective_sample_size(np.array([-1.0, 2.0])), 1.0)
+        self.assertAlmostEqual(_effective_sample_size(np.array([np.nan, 1.0, 1.0])), 2.0)
+
+
+class ComposeSampleWeightsTest(QaTestCase):
+    def _compose(self, base, labels, on_collapse="raise"):
+        return compose_sample_weights(
+            base, labels, logger=LOGGER, context=CONTEXT, on_collapse=on_collapse
+        )
+
+    def test_absent_label_weights_return_the_sanitized_base(self):
+        base = np.array([1.0, 2.0, 3.0])
+        result = self._compose(base, None)
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+        np.testing.assert_allclose(result / result[0], base / base[0], rtol=1e-12, atol=0.0)
+
+    def test_the_composition_has_unit_mean(self):
+        result = self._compose(np.array([1.0, 1.0, 1.0, 1.0]), np.array([3.0, 1.0, 2.0, 1.0]))
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+
+    def test_rows_with_a_non_positive_label_weight_are_exactly_zero(self):
+        result = self._compose(np.ones(4), np.array([1.0, 0.0, -1.0, np.nan]))
+        np.testing.assert_array_equal(result[[1, 2, 3]], np.zeros(3))
+        self.assertGreater(result[0], 0.0)
+
+    def test_surviving_rows_keep_the_base_to_label_product(self):
+        base = np.array([1.0, 2.0])
+        labels = np.array([4.0, 1.0])
+        result = self._compose(base, labels)
+        # The product is 4 and 2, so the second row is half the first.
+        self.assertAlmostEqual(float(result[1] / result[0]), 0.5)
+
+    def test_a_shape_mismatch_is_a_hard_failure(self):
+        with self.assertRaisesRegex(ValueError, "label_weights shape"):
+            self._compose(np.ones(4), np.ones(3))
+
+    def test_every_row_dropped_is_a_support_error_not_a_bare_value_error(self):
+        with self.assertRaises(LabelWeightSupportError):
+            self._compose(np.ones(4), np.zeros(4))
+
+    def test_a_collapse_on_survivors_raises_under_the_raise_policy(self):
+        base = np.array([0.0, 0.0, 1.0, 1.0])
+        labels = np.array([1.0, 1.0, np.nan, np.nan])
+        with self.assertRaises(LabelWeightSupportError):
+            self._compose(base, labels, on_collapse="raise")
+
+    def test_a_collapse_falls_back_to_base_weights_under_the_fallback_policy(self):
+        base = np.array([0.0, 0.0, 1.0, 1.0])
+        labels = np.array([1.0, 1.0, np.nan, np.nan])
+        result = self._compose(base, labels, on_collapse="fallback")
+        self.assertAlmostEqual(float(np.mean(result)), 1.0)
+        self.assertGreater(result[0], 0.0)
+        self.assertEqual(result[2], 0.0)
+
+    def test_the_support_error_is_a_value_error(self):
+        self.assertTrue(issubclass(LabelWeightSupportError, ValueError))
+
+
+class SupportSummaryTest(QaTestCase):
+    def test_a_shape_mismatch_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "label_weights shape"):
+            summarize_label_weight_support(np.ones(4), np.ones(3))
+
+    def test_the_summary_counts_positive_label_weights(self):
+        summary = summarize_label_weight_support(np.array([1.0, 0.0, 2.0, np.nan]), np.ones(4))
+        self.assertEqual(summary.total_rows, 4)
+        self.assertEqual(summary.positive_label_weight_count, 2)
+        self.assertAlmostEqual(summary.positive_label_weight_fraction, 0.5)
+
+    def test_the_summary_reports_kish_ess_on_the_composed_weights(self):
+        summary = summarize_label_weight_support(np.ones(4), np.array([1.0, 1.0, 1.0, 3.0]))
+        self.assertAlmostEqual(
+            summary.effective_sample_size,
+            _effective_sample_size(np.array([1.0, 1.0, 1.0, 3.0])),
+            places=12,
+        )
+
+    def test_pivot_equivalent_rows_are_the_ones_near_the_surviving_maximum(self):
+        # 5.0 dominates, and 0.5 is at the 10% pivot threshold while 0.1 is not.
+        summary = summarize_label_weight_support(np.array([5.0, 0.5, 0.1, 0.0]), np.ones(4))
+        self.assertEqual(summary.pivot_equivalent_count, 2)
+
+    def test_an_empty_input_reports_zeroes(self):
+        summary = summarize_label_weight_support(np.array([]), np.array([]))
+        self.assertEqual(summary.total_rows, 0)
+        self.assertEqual(summary.positive_label_weight_count, 0)
+        self.assertEqual(summary.positive_label_weight_fraction, 0.0)
+        self.assertEqual(summary.effective_sample_size, 0.0)
+
+
+class NanAverageTest(QaTestCase):
+    def test_an_unweighted_average_ignores_non_finite_values(self):
+        self.assertAlmostEqual(nan_average(np.array([1.0, np.nan, 3.0])), 2.0)
+
+    def test_a_weighted_average_follows_the_weights(self):
+        values = np.array([0.0, 10.0])
+        weights = np.array([3.0, 1.0])
+        self.assertAlmostEqual(nan_average(values, weights), 2.5)
+
+    def test_empty_and_all_non_finite_inputs_yield_nan(self):
+        for values in (np.array([]), np.array([np.nan, np.inf])):
+            with self.subTest(values=values.tolist()):
+                self.assertTrue(np.isnan(nan_average(values)))
+
+    def test_a_shape_mismatch_yields_nan(self):
+        self.assertTrue(np.isnan(nan_average(np.ones(4), np.ones(3))))
+
+    def test_a_zero_weight_sum_yields_nan(self):
+        self.assertTrue(np.isnan(nan_average(np.array([1.0, 2.0]), np.array([0.0, 0.0]))))
+
+    def test_rows_with_a_non_finite_weight_are_excluded(self):
+        result = nan_average(np.array([1.0, 100.0, 3.0]), np.array([1.0, np.nan, 1.0]))
+        self.assertAlmostEqual(result, 2.0)
+
+
+class NonZeroDiffTest(QaTestCase):
+    def test_a_zero_difference_becomes_the_float_epsilon(self):
+        result = non_zero_diff(pd.Series([1.0, 2.0]), pd.Series([1.0, 5.0]))
+        self.assertEqual(result.iloc[0], np.finfo(float).eps)
+        self.assertEqual(result.iloc[1], -3.0)
+
+    def test_nan_propagates_unchanged(self):
+        result = non_zero_diff(pd.Series([np.nan]), pd.Series([1.0]))
+        self.assertTrue(np.isnan(result.iloc[0]))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_numeric_safety.py b/quickadapter/tests/test_utils_numeric_safety.py
new file mode 100644 (file)
index 0000000..0acbde7
--- /dev/null
@@ -0,0 +1,182 @@
+"""Numeric safety primitives; requires the Freqtrade QA image."""
+
+import logging
+import math
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import QaTestCase
+from Utils import (
+    FiniteSample,
+    _is_finite_value,
+    finite_sample,
+    is_finite_number,
+    safe_divide,
+    safe_log_ratio,
+)
+
+LOGGER = logging.getLogger("test-numeric-safety")
+
+
+class FiniteSampleTest(QaTestCase):
+    def test_the_partition_invariant_holds(self):
+        for values in ([1.0, np.nan, 2.0], [np.nan] * 4, [1.0, 2.0, 3.0], []):
+            with self.subTest(values=str(values)):
+                sample = finite_sample(np.array(values))
+                self.assertEqual(sample.dropped_count, sample.total_count - sample.finite_count)
+
+    def test_non_finite_entries_are_dropped(self):
+        sample = finite_sample(np.array([1.0, np.nan, np.inf, -np.inf, 2.0]))
+        np.testing.assert_array_equal(sample.values, np.array([1.0, 2.0]))
+        self.assertEqual(sample.total_count, 5)
+        self.assertEqual(sample.finite_count, 2)
+        self.assertEqual(sample.dropped_count, 3)
+
+    def test_the_result_is_flattened_to_one_dimension(self):
+        sample = finite_sample(np.array([[1.0, 2.0], [3.0, 4.0]]))
+        self.assertEqual(sample.values.ndim, 1)
+        self.assertEqual(sample.total_count, 4)
+
+    def test_positive_only_drops_zero_and_signed_zero_strictly(self):
+        sample = finite_sample(np.array([1.0, 0.0, -0.0, -1.0, 2.0]), positive_only=True)
+        np.testing.assert_array_equal(sample.values, np.array([1.0, 2.0]))
+
+    def test_without_positive_only_zero_survives(self):
+        sample = finite_sample(np.array([1.0, 0.0, -1.0]))
+        np.testing.assert_array_equal(sample.values, np.array([1.0, 0.0, -1.0]))
+
+    def test_integer_input_is_coerced_to_float(self):
+        sample = finite_sample(np.array([1, 2, 3]))
+        self.assertEqual(sample.values.dtype, np.dtype(np.float64))
+
+    def test_the_dataclass_does_not_enforce_the_invariant_when_built_directly(self):
+        # Documented escape hatch: only the factory guarantees values are finite.
+        direct = FiniteSample(
+            values=np.array([np.nan]), total_count=1, finite_count=1, dropped_count=0
+        )
+        self.assertEqual(direct.dropped_count, 0)
+
+
+class SafeDivideTest(QaTestCase):
+    def test_a_plain_division_is_exact(self):
+        self.assertAlmostEqual(safe_divide(6.0, 3.0), 2.0)
+
+    def test_a_zero_denominator_falls_back(self):
+        self.assertTrue(math.isnan(safe_divide(1.0, 0.0, fallback=np.nan)))
+        self.assertEqual(safe_divide(1.0, 0.0, fallback=-1.0), -1.0)
+
+    def test_a_subnormal_denominator_passes_through(self):
+        # Satoshi-scale price quotes divide fine; the guard is on exactly zero, not on
+        # magnitude, so a 1e-8 denominator still yields its true quotient.
+        self.assertAlmostEqual(safe_divide(1.0, 1e-8), 1e8)
+
+    def test_non_finite_operands_fall_back(self):
+        for numerator, denominator in ((np.nan, 1.0), (1.0, np.nan), (np.inf, 1.0), (1.0, -np.inf)):
+            with self.subTest(numerator=numerator, denominator=denominator):
+                self.assertTrue(math.isnan(safe_divide(numerator, denominator)))
+
+    def test_an_overflowing_result_is_coerced_to_the_fallback(self):
+        result = safe_divide(1.0, 1e-320, fallback=-1.0)
+        self.assertEqual(result, -1.0)
+
+    def test_element_wise_results_keep_their_shape(self):
+        result = safe_divide(np.array([1.0, 2.0, 3.0]), np.array([1.0, 0.0, 2.0]), fallback=0.0)
+        np.testing.assert_array_equal(result, np.array([1.0, 0.0, 1.5]))
+
+    def test_a_series_input_produces_an_indexed_series(self):
+        result = safe_divide(pd.Series([1.0, 2.0], index=["a", "b"]), pd.Series([1.0, 0.0]))
+        self.assertIsInstance(result, pd.Series)
+        self.assertEqual(list(result.index), ["a", "b"])
+        self.assertTrue(math.isnan(result["b"]))
+
+    def test_a_zero_dimensional_result_is_a_python_float(self):
+        self.assertIsInstance(safe_divide(np.array(4.0), np.array(2.0)), float)
+
+    def test_the_logger_records_how_many_results_were_replaced(self):
+        with self.assertLogs(LOGGER, level="DEBUG") as captured:
+            safe_divide(np.array([1.0, 1.0]), np.array([0.0, 2.0]), logger=LOGGER)
+        self.assertTrue(any("Replaced 1 invalid" in line for line in captured.output))
+
+
+class SafeLogRatioTest(QaTestCase):
+    def test_a_plain_ratio_gives_its_logarithm(self):
+        self.assertAlmostEqual(safe_log_ratio(4.0, 2.0), math.log(2.0), places=12)
+
+    def test_a_ratio_of_unity_is_exactly_zero(self):
+        self.assertAlmostEqual(safe_log_ratio(1e-8, 1e-8), 0.0, places=12)
+
+    def test_a_wide_ratio_survives_where_direct_division_would_underflow(self):
+        # 1e-320 / 1e300 underflows to zero, so log of the ratio would be -inf. The
+        # difference of logarithms keeps it finite, which is the whole point of the form.
+        result = safe_log_ratio(1e-320, 1e300, fallback=np.nan)
+        self.assertTrue(np.isfinite(result))
+        self.assertAlmostEqual(result, math.log(1e-320) - math.log(1e300), places=9)
+
+    def test_a_non_positive_operand_falls_back(self):
+        for numerator, denominator in ((0.0, 1.0), (-1.0, 1.0), (1.0, 0.0), (1.0, -1.0)):
+            with self.subTest(numerator=numerator, denominator=denominator):
+                self.assertTrue(math.isnan(safe_log_ratio(numerator, denominator)))
+
+    def test_non_finite_operands_fall_back(self):
+        for numerator, denominator in ((np.nan, 1.0), (1.0, np.nan), (np.inf, 1.0)):
+            with self.subTest(numerator=numerator, denominator=denominator):
+                self.assertTrue(math.isnan(safe_log_ratio(numerator, denominator)))
+
+    def test_element_wise_results_keep_their_shape(self):
+        result = safe_log_ratio(
+            np.array([4.0, 1.0, 0.0]), np.array([2.0, 0.0, 2.0]), fallback=np.nan
+        )
+        self.assertAlmostEqual(result[0], math.log(2.0), places=12)
+        self.assertTrue(np.isnan(result[1]))
+        self.assertTrue(np.isnan(result[2]))
+
+    def test_a_series_input_produces_an_indexed_series(self):
+        result = safe_log_ratio(pd.Series([4.0], index=["a"]), pd.Series([2.0]))
+        self.assertIsInstance(result, pd.Series)
+        self.assertEqual(list(result.index), ["a"])
+
+
+class FinitenessTest(QaTestCase):
+    def test_finite_numbers_are_accepted(self):
+        for value in (1, -1, 0, 1.5, np.int64(3), np.float64(2.5)):
+            with self.subTest(value=repr(value)):
+                self.assertTrue(is_finite_number(value))
+
+    def test_booleans_are_rejected_despite_being_integers(self):
+        for value in (True, False, np.bool_(True)):
+            with self.subTest(value=repr(value)):
+                self.assertFalse(is_finite_number(value))
+
+    def test_non_finite_numbers_are_rejected(self):
+        for value in (np.nan, np.inf, -np.inf):
+            with self.subTest(value=repr(value)):
+                self.assertFalse(is_finite_number(value))
+
+    def test_non_numeric_values_are_rejected(self):
+        for value in ("1.0", None, object(), [1.0], {"a": 1}):
+            with self.subTest(value=repr(value)):
+                self.assertFalse(is_finite_number(value))
+
+    def test_a_python_int_beyond_the_numpy_range_is_still_finite(self):
+        # np.isfinite raises above 2**64, so the int branch must short-circuit.
+        huge = 2**70
+        self.assertTrue(is_finite_number(huge))
+        self.assertTrue(_is_finite_value(huge))
+
+    def test_a_multi_element_array_is_treated_as_non_finite(self):
+        # np.isfinite returns a non-scalar here, and bool() on it raises, which the
+        # guard turns into "not finite" rather than letting it escape.
+        self.assertFalse(_is_finite_value(np.array([1.0, 2.0])))
+
+    def test_a_single_element_array_is_a_de_facto_scalar(self):
+        # bool() of a one-element array does not raise, so it is reported as finite.
+        # Asserted because it is the boundary the guard's rationale glosses over.
+        self.assertTrue(_is_finite_value(np.array([1.0])))
+
+    def test_an_empty_array_is_treated_as_non_finite(self):
+        self.assertFalse(_is_finite_value(np.array([])))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_regressor_plumbing.py b/quickadapter/tests/test_utils_regressor_plumbing.py
new file mode 100644 (file)
index 0000000..84205d1
--- /dev/null
@@ -0,0 +1,493 @@
+"""Regressor refit, alias and Optuna-range plumbing contracts; requires the Freqtrade QA image."""
+
+import unittest
+from typing import Any
+
+import numpy as np
+import pandas as pd
+from EnumErrors import enum_error_message
+from qa_support import QaTestCase
+from Utils import (
+    _EARLY_STOPPING_ROUNDS_DEFAULT,
+    _NGBOOST_DISTRIBUTIONS,
+    _REGRESSOR_SPEC_BY_NAME,
+    REGRESSORS,
+    _apply_verbosity_alias,
+    _build_int_range,
+    _optuna_suggest_int_from_range,
+    _pop_early_stopping_rounds,
+    get_ngboost_dist,
+    get_refit_model_training_parameters,
+    make_test_set_and_weights,
+    resolve_optuna_model_parameters,
+)
+
+
+class _Booster:
+    def __init__(self, rounds: int) -> None:
+        self._rounds = rounds
+
+    def num_boosted_rounds(self) -> int:
+        return self._rounds
+
+
+class _XGBoostModel:
+    def __init__(self, rounds: int) -> None:
+        self._booster = _Booster(rounds)
+
+    def get_booster(self) -> _Booster:
+        return self._booster
+
+
+class _LightGBMBooster:
+    def __init__(self, current_iteration: int) -> None:
+        self._current_iteration = current_iteration
+
+    def current_iteration(self) -> int:
+        return self._current_iteration
+
+
+class _LightGBMModel:
+    def __init__(self, n_estimators: int, best_iteration: int | None = None) -> None:
+        if best_iteration is not None:
+            self.best_iteration_ = best_iteration
+        self.n_estimators_ = n_estimators
+
+    @property
+    def booster_(self) -> _LightGBMBooster:
+        return _LightGBMBooster(self._current_iteration)
+
+    def warm_start(self, current_iteration: int) -> "_LightGBMModel":
+        model = _LightGBMModel(self.n_estimators_, getattr(self, "best_iteration_", None))
+        model._current_iteration = current_iteration
+        return model
+
+
+class _HistGradientBoostingModel:
+    def __init__(self, n_iter: int) -> None:
+        self.n_iter_ = n_iter
+
+
+class _NGBoostModel:
+    def __init__(self, base_models: int) -> None:
+        self.base_models = list(range(base_models))
+
+
+class _CatBoostModel:
+    def __init__(self, tree_count: int) -> None:
+        self.tree_count_ = tree_count
+
+
+class _RecordingTrial:
+    def __init__(self) -> None:
+        self.int_calls: list[tuple[str, int, int, bool]] = []
+
+    def suggest_int(self, name: str, low: int, high: int, log: bool = False) -> int:
+        self.int_calls.append((name, low, high, log))
+        return low if low == high else (low + high) // 2
+
+
+def recording_trial() -> _RecordingTrial:
+    return _RecordingTrial()
+
+
+def validation_split() -> tuple[pd.DataFrame, pd.DataFrame, np.ndarray]:
+    features = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]})
+    targets = pd.DataFrame({"&-s_close": [0.1, 0.2, 0.3]})
+    weights = np.array([1.0, 2.0, 3.0])
+    return features, targets, weights
+
+
+class UtilsRegressorPlumbingTest(QaTestCase):
+    def test_exactly_one_iteration_parameter_survives_a_refit_for_every_regressor(self):
+        # The alias table is the reason CatBoost does not abort on a duplicate
+        # iteration parameter: every alias must be gone, and the canonical one set.
+        cases = {
+            "xgboost": (_XGBoostModel(77), 77),
+            "lightgbm": (_LightGBMModel(250), 250),
+            "histgradientboostingregressor": (_HistGradientBoostingModel(30), 30),
+            "ngboost": (_NGBoostModel(17), 17),
+            "catboost": (_CatBoostModel(120), 120),
+        }
+        self.assertEqual(sorted(cases), sorted(REGRESSORS))
+
+        for regressor, (model, expected) in cases.items():
+            with self.subTest(regressor=regressor):
+                spec = _REGRESSOR_SPEC_BY_NAME[regressor]
+                training_parameters = dict.fromkeys(sorted(spec.iteration_aliases), 999)
+
+                refit_parameters = get_refit_model_training_parameters(
+                    regressor, model, training_parameters
+                )
+
+                iteration_keys = [key for key in refit_parameters if key in spec.iteration_aliases]
+                self.assertEqual(iteration_keys, [spec.iteration_param])
+                self.assertEqual(refit_parameters[spec.iteration_param], expected)
+
+    def test_a_refit_drops_every_alias_even_when_they_agree_with_each_other(self):
+        spec = _REGRESSOR_SPEC_BY_NAME["catboost"]
+        self.assertEqual(
+            sorted(spec.iteration_aliases),
+            ["iterations", "n_estimators", "num_boost_round", "num_trees"],
+        )
+        training_parameters = dict.fromkeys(sorted(spec.iteration_aliases), 120)
+
+        refit_parameters = get_refit_model_training_parameters(
+            "catboost", _CatBoostModel(120), training_parameters
+        )
+
+        self.assertEqual(refit_parameters, {"iterations": 120})
+
+    def test_a_refit_keeps_every_parameter_that_is_not_an_iteration_alias(self):
+        training_parameters = {
+            "max_depth": 6,
+            "learning_rate": 0.1,
+            "n_estimators": 999,
+            "num_boost_round": 999,
+        }
+
+        refit_parameters = get_refit_model_training_parameters(
+            "xgboost", _XGBoostModel(77), training_parameters
+        )
+
+        self.assertEqual(
+            refit_parameters,
+            {"max_depth": 6, "learning_rate": 0.1, "n_estimators": 77},
+        )
+
+    def test_a_refit_preserves_a_degenerate_capacity_by_clamping_to_one(self):
+        # A fit that added no rounds over the warm start, and a fit that added none
+        # at all, both degrade to a single round rather than to zero or a raise.
+        warm_start = _XGBoostModel(77)
+        self.assertEqual(
+            get_refit_model_training_parameters("xgboost", _XGBoostModel(77), {}, warm_start),
+            {"n_estimators": 1},
+        )
+        self.assertEqual(
+            get_refit_model_training_parameters("catboost", _CatBoostModel(0), {}),
+            {"iterations": 1},
+        )
+        self.assertEqual(
+            get_refit_model_training_parameters("ngboost", _NGBoostModel(0), {}),
+            {"n_estimators": 1},
+        )
+
+    def test_a_refit_preserves_only_the_rounds_a_warm_start_has_not_fitted_yet(self):
+        self.assertEqual(
+            get_refit_model_training_parameters(
+                "xgboost", _XGBoostModel(77), {}, _XGBoostModel(20)
+            ),
+            {"n_estimators": 57},
+        )
+        self.assertEqual(
+            get_refit_model_training_parameters(
+                "lightgbm",
+                _LightGBMModel(250).warm_start(90),
+                {},
+                _LightGBMModel(250).warm_start(90),
+            ),
+            {"n_estimators": 160},
+        )
+
+    def test_a_lightgbm_refit_prefers_a_positive_best_iteration_over_n_estimators(self):
+        # best_iteration_ == 0 and == -1 both mean "no early stopping ran", so the
+        # configured n_estimators_ is the only capacity information left.
+        self.assertEqual(
+            get_refit_model_training_parameters("lightgbm", _LightGBMModel(250, 40), {}),
+            {"n_estimators": 40},
+        )
+        for unused_best_iteration in (0, -1):
+            with self.subTest(best_iteration=unused_best_iteration):
+                self.assertEqual(
+                    get_refit_model_training_parameters(
+                        "lightgbm", _LightGBMModel(250, unused_best_iteration), {}
+                    ),
+                    {"n_estimators": 250},
+                )
+
+    def test_a_refit_does_not_mutate_or_share_the_caller_parameters(self):
+        nested = [1, 2]
+        training_parameters: dict[str, Any] = {"custom": nested, "n_estimators": 999}
+
+        refit_parameters = get_refit_model_training_parameters(
+            "xgboost", _XGBoostModel(77), training_parameters
+        )
+
+        self.assertEqual(training_parameters, {"custom": [1, 2], "n_estimators": 999})
+        self.assertIsNot(refit_parameters["custom"], nested)
+        refit_parameters["custom"].append(3)
+        self.assertEqual(nested, [1, 2])
+
+    def test_a_histgradientboosting_refit_disables_early_stopping(self):
+        refit_parameters = get_refit_model_training_parameters(
+            "histgradientboostingregressor",
+            _HistGradientBoostingModel(30),
+            {"early_stopping": True, "early_stopping_rounds": 17},
+        )
+
+        self.assertEqual(
+            refit_parameters,
+            {"early_stopping": False, "early_stopping_rounds": 17, "max_iter": 30},
+        )
+
+    def test_a_refit_rejects_an_unknown_regressor_with_the_canonical_enum_error(self):
+        with self.assertRaises(ValueError) as caught:
+            get_refit_model_training_parameters("randomforest", _CatBoostModel(1), {})
+
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("regressor", "randomforest", REGRESSORS),
+        )
+
+    def test_early_stopping_rounds_are_returned_and_consumed_when_an_eval_set_exists(self):
+        training_parameters = {"early_stopping_rounds": 17}
+
+        rounds = _pop_early_stopping_rounds(training_parameters, True)
+
+        self.assertEqual(rounds, 17)
+        self.assertEqual(training_parameters, {})
+
+    def test_early_stopping_rounds_fall_back_to_the_default_when_the_key_is_absent(self):
+        training_parameters: dict[str, Any] = {}
+
+        rounds = _pop_early_stopping_rounds(training_parameters, True)
+
+        self.assertEqual(rounds, _EARLY_STOPPING_ROUNDS_DEFAULT)
+        self.assertEqual(rounds, 50)
+        self.assertEqual(training_parameters, {})
+
+    def test_early_stopping_rounds_are_removed_without_an_eval_set(self):
+        # The removal, not the return value, is the contract: a refit must not carry
+        # an early-stopping parameter when there is nothing to stop on.
+        training_parameters = {"early_stopping_rounds": 17}
+
+        rounds = _pop_early_stopping_rounds(training_parameters, False)
+
+        self.assertIsNone(rounds)
+        self.assertEqual(training_parameters, {})
+
+    def test_a_missing_early_stopping_key_without_an_eval_set_leaves_the_parameters_alone(self):
+        training_parameters = {"max_depth": 6}
+
+        rounds = _pop_early_stopping_rounds(training_parameters, False)
+
+        self.assertIsNone(rounds)
+        self.assertEqual(training_parameters, {"max_depth": 6})
+
+    def test_verbosity_is_renamed_to_verbose_when_verbose_is_absent(self):
+        training_parameters = {"verbosity": 2, "max_depth": 6}
+
+        _apply_verbosity_alias(training_parameters)
+
+        self.assertEqual(training_parameters, {"verbose": 2, "max_depth": 6})
+
+    def test_an_explicit_verbose_wins_and_the_verbosity_alias_is_still_removed(self):
+        training_parameters = {"verbosity": 2, "verbose": 0}
+
+        _apply_verbosity_alias(training_parameters)
+
+        self.assertEqual(training_parameters, {"verbose": 0})
+
+    def test_a_verbosity_of_zero_is_renamed_because_it_is_set_rather_than_truthy(self):
+        training_parameters = {"verbosity": 0}
+
+        _apply_verbosity_alias(training_parameters)
+
+        self.assertEqual(training_parameters, {"verbose": 0})
+
+    def test_verbosity_is_dropped_without_a_value_when_it_is_explicitly_none(self):
+        training_parameters = {"verbosity": None, "max_depth": 6}
+
+        _apply_verbosity_alias(training_parameters)
+
+        self.assertEqual(training_parameters, {"max_depth": 6})
+
+    def test_verbosity_aliasing_leaves_a_verbose_only_mapping_untouched(self):
+        training_parameters = {"verbose": 1}
+
+        _apply_verbosity_alias(training_parameters)
+
+        self.assertEqual(training_parameters, {"verbose": 1})
+
+    def test_lossguide_resolves_to_an_unbounded_xgboost_depth(self):
+        resolved = resolve_optuna_model_parameters(
+            "xgboost", {"grow_policy": "lossguide", "max_depth": 6, "max_leaves": 64}
+        )
+
+        self.assertEqual(resolved, {"grow_policy": "lossguide", "max_depth": 0, "max_leaves": 64})
+
+    def test_only_lossguide_forces_the_depth_to_zero(self):
+        cases: tuple[tuple[str, dict[str, Any]], ...] = (
+            ("xgboost", {"grow_policy": "depthwise", "max_depth": 6}),
+            ("xgboost", {"max_depth": 6}),
+            ("xgboost", {"grow_policy": "LossGuide", "max_depth": 6}),
+            ("lightgbm", {"grow_policy": "lossguide", "max_depth": 6}),
+            ("catboost", {"grow_policy": "lossguide", "max_depth": 6}),
+            ("ngboost", {"grow_policy": "lossguide", "max_depth": 6}),
+        )
+        for regressor, params in cases:
+            with self.subTest(regressor=regressor, params=params):
+                resolved = resolve_optuna_model_parameters(regressor, dict(params))
+
+                self.assertEqual(resolved, params)
+
+    def test_the_l2_regularization_zero_suggestion_maps_onto_l2_regularization(self):
+        # The raw suggestion is a boolean because the search space spans both zero
+        # and a continuous range; the estimator only accepts the numeric parameter.
+        resolved = resolve_optuna_model_parameters(
+            "histgradientboostingregressor",
+            {"l2_regularization_zero": True, "l2_regularization": 3.0, "max_leaf_nodes": 31},
+        )
+
+        self.assertEqual(resolved, {"l2_regularization": 0.0, "max_leaf_nodes": 31})
+
+    def test_a_rejected_l2_regularization_zero_suggestion_is_dropped_not_forwarded(self):
+        resolved = resolve_optuna_model_parameters(
+            "histgradientboostingregressor", {"l2_regularization_zero": False}
+        )
+
+        self.assertEqual(resolved, {})
+
+    def test_an_absent_l2_regularization_zero_leaves_the_numeric_parameter_alone(self):
+        resolved = resolve_optuna_model_parameters(
+            "histgradientboostingregressor", {"l2_regularization": 3.0}
+        )
+
+        self.assertEqual(resolved, {"l2_regularization": 3.0})
+
+    def test_the_l2_regularization_zero_mapping_belongs_to_histgradientboosting_alone(self):
+        resolved = resolve_optuna_model_parameters("xgboost", {"l2_regularization_zero": True})
+
+        self.assertEqual(resolved, {"l2_regularization_zero": True})
+
+    def test_resolving_optuna_parameters_does_not_mutate_the_caller_mapping(self):
+        params = {"grow_policy": "lossguide", "max_depth": 6}
+
+        resolve_optuna_model_parameters("xgboost", params)
+
+        self.assertEqual(params, {"grow_policy": "lossguide", "max_depth": 6})
+
+    def test_a_usable_range_is_rounded_outward_to_whole_iterations(self):
+        self.assertEqual(_build_int_range((1.2, 5.9)), (2, 5))
+        self.assertEqual(_build_int_range((3.0, 7.0)), (3, 7))
+        self.assertEqual(_build_int_range((4.0, 4.0)), (4, 4))
+
+    def test_an_inverted_range_collapses_onto_its_rounded_midpoint(self):
+        # A space-reduction pass can invert a range; collapsing keeps the study
+        # running at one value instead of handing Optuna an empty range.
+        cases: tuple[tuple[tuple[float, float], tuple[int, int]], ...] = (
+            ((10.0, 2.0), (6, 6)),
+            ((5.0, 2.0), (4, 4)),
+            ((4.0, 1.0), (2, 2)),
+            ((2.6, 2.4), (2, 2)),
+        )
+        for frange, expected in cases:
+            with self.subTest(frange=frange):
+                self.assertEqual(_build_int_range(frange), expected)
+
+    def test_the_declared_minimum_is_enforced_on_both_ends_of_a_range(self):
+        self.assertEqual(_build_int_range((2.0, 6.0), 5), (5, 6))
+        self.assertEqual(_build_int_range((3.0, 7.0), 2), (3, 7))
+        self.assertEqual(_build_int_range((0.2, 0.8), 5), (5, 5))
+
+    def test_the_declared_minimum_is_enforced_on_a_collapsed_range(self):
+        self.assertEqual(_build_int_range((0.0, -3.0)), (1, 1))
+        self.assertEqual(_build_int_range((0.0, -3.0), 2), (2, 2))
+        self.assertEqual(_build_int_range((10.0, 2.0), 1), (6, 6))
+
+    def test_an_inverted_range_is_suggested_as_its_midpoint(self):
+        trial = recording_trial()
+
+        value = _optuna_suggest_int_from_range(trial, "max_depth", (10.0, 2.0))
+
+        self.assertEqual(value, 6)
+        self.assertEqual(trial.int_calls, [("max_depth", 6, 6, False)])
+
+    def test_a_suggestion_never_falls_below_the_declared_minimum(self):
+        cases: tuple[tuple[str, tuple[float, float], int, int], ...] = (
+            ("max_depth", (0.0, -3.0), 2, 2),
+            ("max_depth", (0.2, 0.8), 5, 5),
+            ("learning_rate", (0.005, 0.3), 1, 1),
+            ("max_leaves", (16.0, 32.0), 2, 24),
+        )
+        for name, frange, min_val, expected in cases:
+            with self.subTest(name=name, frange=frange, min_val=min_val):
+                trial = recording_trial()
+
+                value = _optuna_suggest_int_from_range(
+                    trial, name, frange, min_val=min_val, log=True
+                )
+
+                low, high = _build_int_range(frange, min_val=min_val)
+                self.assertGreaterEqual(low, min_val)
+                self.assertGreaterEqual(high, low)
+                self.assertEqual(trial.int_calls, [(name, low, high, True)])
+                self.assertGreaterEqual(value, min_val)
+                self.assertEqual(value, expected)
+
+    def test_every_distribution_name_resolves_to_its_ngboost_class(self):
+        from ngboost.distns import Exponential, Laplace, LogNormal, Normal, T
+
+        cases = (
+            ("normal", Normal),
+            ("lognormal", LogNormal),
+            ("exponential", Exponential),
+            ("laplace", Laplace),
+            ("t", T),
+        )
+        self.assertEqual(tuple(_NGBOOST_DISTRIBUTIONS), tuple(name for name, _ in cases))
+
+        for dist_name, dist_class in cases:
+            with self.subTest(dist_name=dist_name):
+                self.assertIs(get_ngboost_dist(dist_name), dist_class)
+
+    def test_an_unknown_distribution_raises_through_the_canonical_enum_error(self):
+        with self.assertRaises(ValueError) as caught:
+            get_ngboost_dist("poisson")
+
+        self.assertEqual(
+            str(caught.exception),
+            enum_error_message("dist_name", "poisson", _NGBOOST_DISTRIBUTIONS),
+        )
+
+    def test_a_positive_test_size_wraps_the_validation_split_for_model_fit(self):
+        features, targets, weights = validation_split()
+
+        eval_set, eval_weights = make_test_set_and_weights(features, targets, weights, 0.25)
+
+        self.assertEqual(len(eval_set), 1)
+        self.assertEqual(len(eval_weights), 1)
+        np.testing.assert_array_equal(eval_set[0][0].to_numpy(), features.to_numpy())
+        np.testing.assert_array_equal(eval_set[0][1].to_numpy(), targets.to_numpy())
+        self.assertEqual(eval_weights[0].ndim, 1)
+        self.assertEqual(eval_weights[0].shape, (len(features),))
+        # eval_set[0] must be the caller's own frames: fit_regressor ravel y but not X,
+        # and a copy here would silently drop the caller's index alignment.
+        self.assertIs(eval_set[0][0], features)
+        self.assertIs(eval_set[0][1], targets)
+        self.assertIs(eval_weights[0], weights)
+
+    def test_a_non_positive_test_size_suppresses_evaluation_entirely(self):
+        features, targets, weights = validation_split()
+
+        for test_size in (0.0, -0.5):
+            with self.subTest(test_size=test_size):
+                self.assertEqual(
+                    make_test_set_and_weights(features, targets, weights, test_size),
+                    (None, None),
+                )
+
+    def test_the_weight_vector_is_passed_through_without_reshaping_or_validation(self):
+        # The helper never inspects the weights: alignment is the caller's contract
+        # (both call sites pass a vector already indexed to the validation rows), so
+        # this pins that a mismatched shape is forwarded rather than repaired.
+        features, targets, _ = validation_split()
+
+        eval_set, eval_weights = make_test_set_and_weights(features, targets, np.ones((2, 3)), 0.25)
+
+        self.assertEqual(eval_weights[0].shape, (2, 3))
+        self.assertIs(eval_set[0][0], features)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_step_math.py b/quickadapter/tests/test_utils_step_math.py
new file mode 100644 (file)
index 0000000..c0d1a4c
--- /dev/null
@@ -0,0 +1,122 @@
+"""Step and period algebra; requires the Freqtrade QA image."""
+
+import unittest
+
+import numpy as np
+from qa_support import QaTestCase
+from Utils import (
+    ceil_to_step,
+    floor_to_step,
+    get_min_max_label_period_candles,
+    largest_divisor_to_step,
+    round_to_step,
+)
+
+
+class StepTest(QaTestCase):
+    def test_round_to_step_snaps_to_the_nearest_multiple(self):
+        # 8 and 4 are not multiples of 3; the nearest ones are 9 and 3 respectively.
+        for value, expected in ((7, 6), (8, 9), (5, 6), (4, 3), (0, 0), (9, 9)):
+            with self.subTest(value=value):
+                self.assertEqual(round_to_step(value, 3), expected)
+                self.assertEqual(round_to_step(value, 3) % 3, 0)
+
+    def test_floor_and_ceil_bracket_the_value(self):
+        for value in (4, 5, 6, 7, 8):
+            with self.subTest(value=value):
+                low = floor_to_step(value, 3)
+                high = ceil_to_step(value, 3)
+                self.assertLessEqual(low, value)
+                self.assertGreaterEqual(high, value)
+                self.assertLessEqual(value - low, 3)
+                self.assertLessEqual(high - value, 3)
+
+    def test_an_exact_multiple_is_its_own_floor_and_ceiling(self):
+        self.assertEqual(floor_to_step(9, 3), 9)
+        self.assertEqual(ceil_to_step(9, 3), 9)
+        self.assertEqual(round_to_step(9, 3), 9)
+
+    def test_a_non_positive_step_is_refused(self):
+        for step in (0, -1):
+            with self.subTest(step=step):
+                for function in (round_to_step, ceil_to_step, floor_to_step):
+                    with self.assertRaises(ValueError):
+                        function(5, step)
+
+    def test_a_non_numeric_value_is_refused(self):
+        for value in ("5", None, np.nan):
+            with self.subTest(value=repr(value)), self.assertRaises(ValueError):
+                round_to_step(value, 3)
+
+    def test_an_exact_half_rounds_to_even_for_floats(self):
+        # Covers the FLOAT branch only. The int branch has its own tie handler, which no
+        # float input can reach, and the two halves must not be read as covered by one.
+        self.assertEqual(round_to_step(2.5, 1), 2)
+        self.assertEqual(round_to_step(3.5, 1), 4)
+        self.assertEqual(round_to_step(0.5, 1), 0)
+        self.assertEqual(round_to_step(1.5, 1), 2)
+
+    def test_an_exact_half_rounds_to_even_for_integers_too(self):
+        # An int-typed value takes the other branch entirely, whose tie handler rounds up
+        # when the remainder is exactly half of the step. Only an ODD quotient discriminates,
+        # because half-to-even then rounds away from zero: (3,2) is 1.5 steps and must go to
+        # 4, where a naive q*step would give 2. An even quotient like (5,2) is 2.5 steps and
+        # agrees either way, so it is listed only as a control.
+        for value, step, expected in ((3, 2, 4), (6, 4, 8), (9, 6, 12), (5, 2, 4), (7, 4, 8)):
+            with self.subTest(value=value, step=step):
+                self.assertEqual(round_to_step(value, step), expected)
+                self.assertEqual(round_to_step(value, step) % step, 0)
+
+
+class LargestDivisorTest(QaTestCase):
+    def test_it_returns_the_largest_multiple_of_the_step_that_divides_the_integer(self):
+        # 12 is a multiple of 4, so 12 comes back; 60 is a multiple of both 3 and 4.
+        for integer, step, expected in ((12, 4, 12), (12, 12, 12), (60, 3, 60), (60, 4, 60)):
+            with self.subTest(integer=integer, step=step):
+                self.assertEqual(largest_divisor_to_step(integer, step), expected)
+
+    def test_it_returns_none_when_no_multiple_of_the_step_divides_the_integer(self):
+        # 12 has divisors 1, 2, 3, 4, 6, 12; none of those is a multiple of 5.
+        for integer, step in ((12, 5), (13, 5), (60, 7)):
+            with self.subTest(integer=integer, step=step):
+                self.assertIsNone(largest_divisor_to_step(integer, step))
+
+    def test_a_step_of_one_always_yields_the_integer_itself(self):
+        self.assertEqual(largest_divisor_to_step(12, 1), 12)
+        self.assertEqual(largest_divisor_to_step(13, 1), 13)
+
+    def test_a_non_positive_or_non_integer_argument_is_refused(self):
+        for integer, step in ((0, 5), (-3, 5), (12, 0), (12, -2), (1.5, 5), (12, 2.5)):
+            with self.subTest(integer=integer, step=step), self.assertRaises(ValueError):
+                largest_divisor_to_step(integer, step)
+
+
+class LabelPeriodRangeTest(QaTestCase):
+    def _range(self, fit_live, step, low, high, **kwargs):
+        return get_min_max_label_period_candles(fit_live, step, low, high, **kwargs)
+
+    def test_a_range_the_budget_can_carry_keeps_both_endpoints(self):
+        # A horizon of 300/3 = 100 leaves room for the requested 12..24 on a step of 3.
+        self.assertEqual(self._range(300, 3, 12, 24), (12, 24, 3))
+
+    def test_endpoints_are_snapped_onto_the_step(self):
+        # With a 150-candle budget and a step of 7, 10 rounds up to 14 and 40 down to 35.
+        self.assertEqual(self._range(150, 7, 10, 40), (14, 35, 7))
+
+    def test_the_horizon_cap_shrinks_the_maximum_below_the_requested_one(self):
+        # A 12-candle budget allows a horizon of ceil(12/3) = 4, floored to 3 on the step,
+        # so the requested maximum of 24 is capped to 3 and both endpoints collapse.
+        self.assertEqual(self._range(12, 3, 12, 24), (3, 3, 1))
+
+    def test_a_reversed_range_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "min must be <= max"):
+            self._range(300, 3, 24, 12)
+
+    def test_a_range_narrower_than_the_step_collapses_to_the_step_of_one(self):
+        low, high, step = self._range(300, 3, 12, 13)
+        self.assertEqual(step, 1)
+        self.assertEqual((low, high), (12, 13))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_validation_algebra.py b/quickadapter/tests/test_utils_validation_algebra.py
new file mode 100644 (file)
index 0000000..5a18550
--- /dev/null
@@ -0,0 +1,296 @@
+"""Tunable validation algebra; requires the Freqtrade QA image."""
+
+import logging
+import typing
+import unittest
+
+import numpy as np
+from qa_support import QaTestCase
+from Utils import (
+    _BoolValidator,
+    _DictValidator,
+    _EnumValidator,
+    _NumericValidator,
+    _ParamSpec,
+    _RangeValidator,
+    _validate_params,
+    require_bool,
+    require_numeric,
+    validate_range,
+)
+
+LOGGER = logging.getLogger("test-validation")
+NAME = "sample"
+
+
+class EnumValidatorTest(QaTestCase):
+    def test_membership_is_the_whole_contract(self):
+        validator = _EnumValidator(("none", "uniform"))
+        self.assertTrue(validator("none"))
+        self.assertFalse(validator("zigzag"))
+        self.assertFalse(validator(None))
+
+    def test_the_message_lists_the_supported_values(self):
+        self.assertEqual(_EnumValidator(("a", "b")).message(NAME), "supported values are a, b")
+
+
+class NumericValidatorTest(QaTestCase):
+    def test_bounds_are_inclusive_by_default(self):
+        validator = _NumericValidator(min_value=0.0, max_value=1.0)
+        self.assertTrue(validator(0.0))
+        self.assertTrue(validator(1.0))
+        self.assertFalse(validator(-0.1))
+        self.assertFalse(validator(1.1))
+
+    def test_exclusive_bounds_reject_the_endpoint(self):
+        validator = _NumericValidator(
+            min_value=0.0, max_value=1.0, min_exclusive=True, max_exclusive=True
+        )
+        self.assertFalse(validator(0.0))
+        self.assertFalse(validator(1.0))
+        self.assertTrue(validator(0.5))
+
+    def test_booleans_are_refused_even_though_they_are_integers(self):
+        validator = _NumericValidator()
+        self.assertFalse(validator(True))
+        self.assertFalse(validator(False))
+
+    def test_non_finite_values_are_refused(self):
+        validator = _NumericValidator()
+        for value in (np.nan, np.inf, -np.inf):
+            with self.subTest(value=repr(value)):
+                self.assertFalse(validator(value))
+
+    def test_require_int_refuses_a_float(self):
+        validator = _NumericValidator(require_int=True)
+        self.assertTrue(validator(3))
+        self.assertFalse(validator(3.0))
+
+    def test_the_message_reflects_the_declared_constraints(self):
+        self.assertEqual(
+            _NumericValidator(min_value=0.0, max_value=1.0).message(NAME),
+            "must be a finite number >= 0.0 <= 1.0",
+        )
+        self.assertEqual(
+            _NumericValidator(require_int=True, min_value=2, min_exclusive=True).message(NAME),
+            "must be an integer > 2",
+        )
+
+
+class RangeValidatorTest(QaTestCase):
+    def test_a_rising_pair_is_accepted(self):
+        validator = _RangeValidator()
+        self.assertTrue(validator((1, 2)))
+        self.assertTrue(validator([1.5, 2.5]))
+
+    def test_an_equal_pair_is_refused(self):
+        self.assertFalse(_RangeValidator()((1, 1)))
+
+    def test_a_falling_pair_is_refused(self):
+        self.assertFalse(_RangeValidator()((2, 1)))
+
+    def test_the_pair_must_have_exactly_two_finite_numbers(self):
+        validator = _RangeValidator()
+        for value in ((1,), (1, 2, 3), (1, np.nan), (1, "2"), 5, None):
+            with self.subTest(value=repr(value)):
+                self.assertFalse(validator(value))
+
+    def test_outer_bounds_are_enforced(self):
+        validator = _RangeValidator(min_bound=1.0, max_bound=10.0)
+        self.assertTrue(validator((1.0, 10.0)))
+        self.assertFalse(validator((0.5, 10.0)))
+        self.assertFalse(validator((1.0, 10.5)))
+
+    def test_the_message_states_the_closed_form(self):
+        self.assertEqual(_RangeValidator().message(NAME), "must be (low, high) with low < high")
+        self.assertEqual(
+            _RangeValidator(min_bound=1.0, max_bound=9.0).message(NAME),
+            "must be (low, high) with 1.0 <= low < high <= 9.0",
+        )
+
+
+class DictAndBoolValidatorTest(QaTestCase):
+    def test_the_dict_validator_accepts_any_mapping(self):
+        validator = _DictValidator()
+        self.assertTrue(validator({}))
+        self.assertTrue(validator({"a": 1}))
+        self.assertFalse(validator([("a", 1)]))
+        self.assertFalse(validator(None))
+
+    def test_the_bool_validator_is_strict(self):
+        validator = _BoolValidator()
+        self.assertTrue(validator(True))
+        self.assertFalse(validator(1))
+        self.assertFalse(validator("true"))
+
+
+class ValidateParamsTest(QaTestCase):
+    SPECS: typing.ClassVar[dict] = {
+        "mode": _ParamSpec(_EnumValidator(("a", "b"))),
+        "scale": _ParamSpec(_NumericValidator(min_value=0.0), output_type=float),
+        "window": _ParamSpec(_NumericValidator(require_int=True), output_type=int),
+        "pair": _ParamSpec(_RangeValidator(), output_type=tuple),
+        "enabled": _ParamSpec(_BoolValidator()),
+        "options": _ParamSpec(_DictValidator(valid_keys=("x", "y"))),
+    }
+    DEFAULTS: typing.ClassVar[dict] = {
+        "mode": "a",
+        "scale": 1.0,
+        "window": 3,
+        "pair": (0, 1),
+        "enabled": False,
+        "options": {"x": 1},
+    }
+
+    def _validate(self, config):
+        return _validate_params(config, LOGGER, "test", self.SPECS, self.DEFAULTS)
+
+    def test_every_specified_key_is_present_in_the_result(self):
+        self.assertEqual(set(self._validate({})), set(self.SPECS))
+
+    def test_a_missing_key_takes_its_default(self):
+        self.assertEqual(self._validate({})["mode"], "a")
+
+    def test_a_valid_value_is_kept(self):
+        self.assertEqual(self._validate({"mode": "b"})["mode"], "b")
+
+    def test_an_invalid_value_falls_back_to_the_default_with_a_warning(self):
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            result = self._validate({"mode": "zzz"})
+        self.assertEqual(result["mode"], "a")
+        self.assertTrue(any("Invalid test mode" in line for line in captured.output))
+
+    def test_output_type_coercion_is_applied(self):
+        result = self._validate({"scale": 2, "window": 5})
+        self.assertIsInstance(result["scale"], float)
+        self.assertIsInstance(result["window"], int)
+
+    def test_a_list_pair_becomes_a_two_tuple(self):
+        result = self._validate({"pair": [3, 4]})
+        self.assertEqual(result["pair"], (3, 4))
+        self.assertIsInstance(result["pair"], tuple)
+
+    def test_unknown_dictionary_keys_are_dropped_with_a_warning(self):
+        with self.assertLogs(LOGGER, level="WARNING") as captured:
+            result = self._validate({"options": {"x": 1, "zzz": 2}})
+        self.assertEqual(result["options"], {"x": 1})
+        self.assertTrue(any("keys ['zzz']" in line for line in captured.output))
+
+    def test_coercion_runs_even_after_a_fallback(self):
+        result = self._validate({"scale": -1})
+        self.assertIsInstance(result["scale"], float)
+        self.assertEqual(result["scale"], 1.0)
+
+
+class RequireNumericTest(QaTestCase):
+    def test_a_builtin_number_is_returned_unchanged(self):
+        self.assertEqual(require_numeric(2, NAME, context="ctx"), 2)
+        self.assertEqual(require_numeric(2.5, NAME, context="ctx"), 2.5)
+
+    def test_booleans_are_refused(self):
+        with self.assertRaisesRegex(ValueError, "must be a finite number"):
+            require_numeric(True, NAME, context="ctx")
+
+    def test_a_numpy_scalar_is_refused_because_the_type_is_exact(self):
+        # The check is `type(value) not in (int, float)`, so a numpy scalar is rejected
+        # even though it compares and divides like a number.
+        with self.assertRaisesRegex(ValueError, "must be a finite number"):
+            require_numeric(np.float64(1.0), NAME, context="ctx")
+        with self.assertRaisesRegex(ValueError, "must be an integer"):
+            require_numeric(np.int64(1), NAME, context="ctx", require_int=True)
+
+    def test_bounds_are_enforced_with_the_configured_exclusivity(self):
+        with self.assertRaisesRegex(ValueError, "must be a finite number >= 1.0"):
+            require_numeric(0.5, NAME, context="ctx", minimum=1.0)
+        self.assertEqual(require_numeric(1.0, NAME, context="ctx", minimum=1.0), 1.0)
+        with self.assertRaisesRegex(ValueError, "must be a finite number > 1.0"):
+            require_numeric(1.0, NAME, context="ctx", minimum=1.0, min_exclusive=True)
+
+    def test_require_int_refuses_a_float(self):
+        with self.assertRaisesRegex(ValueError, "must be an integer"):
+            require_numeric(1.0, NAME, context="ctx", require_int=True)
+
+    def test_the_message_names_the_context_and_the_parameter(self):
+        with self.assertRaisesRegex(ValueError, r"Invalid ctx\.sample value"):
+            require_numeric("x", NAME, context="ctx")
+
+
+class RequireBoolTest(QaTestCase):
+    def test_a_real_boolean_passes_through(self):
+        self.assertIs(require_bool(True, NAME, context="ctx"), True)
+        self.assertIs(require_bool(False, NAME, context="ctx"), False)
+
+    def test_an_integer_one_is_refused(self):
+        with self.assertRaisesRegex(ValueError, "must be a boolean"):
+            require_bool(1, NAME, context="ctx")
+
+
+class ValidateRangeTest(QaTestCase):
+    def _range(self, low, high, **kwargs):
+        return validate_range(
+            low,
+            high,
+            LOGGER,
+            name="window",
+            default_min=kwargs.pop("default_min", 2),
+            default_max=kwargs.pop("default_max", 10),
+            **kwargs,
+        )
+
+    def test_a_valid_rising_range_is_returned_unchanged(self):
+        self.assertEqual(self._range(3, 8), (3, 8))
+
+    def test_a_falling_range_falls_back_to_the_defaults(self):
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range(8, 3), (2, 10))
+
+    def test_an_equal_range_falls_back_unless_equal_is_allowed(self):
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range(4, 4), (2, 10))
+        self.assertEqual(self._range(4, 4, allow_equal=True), (4, 4))
+
+    def test_a_non_numeric_component_falls_back_on_its_own(self):
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range("x", 8), (2, 8))
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range(3, None), (3, 10))
+
+    def test_a_negative_component_falls_back_by_default(self):
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range(-1, 8), (2, 8))
+
+    def test_a_component_above_the_configured_maximum_falls_back(self):
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range(3, 50, max_value=10), (3, 10))
+
+    def test_non_finite_components_fall_back(self):
+        with self.assertLogs(LOGGER, level="WARNING"):
+            self.assertEqual(self._range(np.nan, 8), (2, 8))
+
+    def test_malformed_defaults_are_a_hard_failure_not_a_fallback(self):
+        for kwargs in (
+            {"default_min": "x"},
+            {"default_min": 10, "default_max": 2},
+            {"default_min": 5, "default_max": 5},
+            {"default_min": 11, "default_max": 20, "max_value": 10},
+        ):
+            with self.subTest(**kwargs), self.assertRaisesRegex(ValueError, "Invalid window"):
+                self._range(3, 8, **kwargs)
+
+    def test_equal_defaults_are_allowed_when_equal_is_permitted(self):
+        self.assertEqual(
+            validate_range(
+                5,
+                5,
+                LOGGER,
+                name="window",
+                default_min=5,
+                default_max=5,
+                allow_equal=True,
+            ),
+            (5, 5),
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/quickadapter/tests/test_utils_zigzag.py b/quickadapter/tests/test_utils_zigzag.py
new file mode 100644 (file)
index 0000000..98c3ba9
--- /dev/null
@@ -0,0 +1,644 @@
+"""Zigzag pivot confirmation, label registration and soft extrema; requires the Freqtrade QA image."""
+
+import logging
+import unittest
+
+import numpy as np
+import pandas as pd
+from qa_support import QaTestCase, ohlcv_frame
+from Utils import (
+    _LABEL_GENERATORS,
+    _ZIGZAG_CONFIRMATION_ALPHA,
+    _ZIGZAG_MIN_CONFIRMATION_SLOPES,
+    EXTREMA_COLUMN,
+    LABEL_COLUMNS,
+    LabelData,
+    TrendDirection,
+    ZigzagResult,
+    _adapt_label_generator,
+    _zigzag,
+    generate_label_data,
+    register_label_generator,
+    soft_extremum,
+    zigzag,
+)
+
+LOGGER = logging.getLogger("test-utils-zigzag")
+
+# natr_period 5 keeps the ATR warm-up short enough that a 45-candle first leg still leaves
+# room for the five consecutive slopes the confirmation test demands.
+NATR_PERIOD = 5
+NATR_MULTIPLIER = 2.0
+PARAMS = {"natr_period": NATR_PERIOD, "natr_multiplier": NATR_MULTIPLIER}
+FAKE_LABEL_COLUMN = "&s-zigzag-fake"
+
+# A clean fall then a clean rise: the peak at index 0 and the trough at index 29.
+V_LEGS = ((100.0, 80.0, 30), (80.0, 120.0, 31))
+# Fall, rise, fall: peak / trough / peak at 0, 29 and 69.
+W_LEGS = ((100.0, 80.0, 30), (80.0, 110.0, 40), (110.0, 90.0, 31))
+# A 45-candle rise into the peak at index 44, reused by the wobble and reversal cases.
+RISE_TO_PEAK = ((100.0, 160.0, 45),)
+# A deep candle after a monotone run in each direction. Each clears exactly one of the two
+# initial amplitude thresholds, so the seed pivot comes from that move alone.
+DIP_AFTER_RISE = ((100.0, 130.0, 20), (95.0, 95.0, 1), (95.0, 120.0, 20))
+RISE_AFTER_FALL = ((130.0, 95.0, 20), (135.0, 135.0, 1), (135.0, 110.0, 20))
+# A confirmed peak followed by a confirmed trough, used for the rebound threshold cases.
+PEAK_AND_TROUGH = ((100.0, 160.0, 45), (160.0, 120.0, 16))
+# The dip depth in the middle of the rising leg of W_LEGS, in price units.
+WIGGLE_DEPTH = 3.0
+WIGGLE_SLICE = slice(15, 18)
+
+METRIC_FIELDS = {
+    "amplitude": "amplitudes",
+    "amplitude_threshold_ratio": "amplitude_threshold_ratios",
+    "volume_rate": "volume_rates",
+    "speed": "speeds",
+    "efficiency_ratio": "efficiency_ratios",
+    "volume_weighted_efficiency_ratio": "volume_weighted_efficiency_ratios",
+}
+
+
+def price_path(*legs: tuple[float, float, int]) -> np.ndarray:
+    """Join (start, stop, candles) linear legs into one close series."""
+    return np.concatenate([np.linspace(start, stop, candles) for start, stop, candles in legs])
+
+
+def zigzag_result(closes: np.ndarray) -> ZigzagResult:
+    """Run ``_zigzag`` on a close series under the shared NATR parameters."""
+    return _zigzag(ohlcv_frame(closes), **PARAMS)
+
+
+def zigzag_on(*legs: tuple[float, float, int]) -> ZigzagResult:
+    """Run ``_zigzag`` on a price path assembled from ``legs``."""
+    return zigzag_result(price_path(*legs))
+
+
+def nested_bar_frame(second_close: float, second_spread: float) -> pd.DataFrame:
+    """Return a frame whose first two wide bars clear both initial thresholds on one candle."""
+    closes = np.concatenate(
+        [
+            [100.0, second_close],
+            np.linspace(second_close, 100.0, 6),
+            np.linspace(100.0, 80.0, 10),
+            np.linspace(80.0, 120.0, 15),
+        ]
+    )
+    spreads = np.full(len(closes), 0.001)
+    spreads[0] = 0.10
+    spreads[1] = second_spread
+    return ohlcv_frame(closes, spread=spreads)
+
+
+def wiggly_closes() -> np.ndarray:
+    """Return the W path with a counter-trend dip inside its rising leg."""
+    fall, rise, decline = (np.linspace(start, stop, candles) for start, stop, candles in W_LEGS)
+    rise = rise.copy()
+    rise[WIGGLE_SLICE] -= WIGGLE_DEPTH
+    return np.concatenate([fall, rise, decline])
+
+
+def soft_extremum_oracle(values: np.ndarray, alpha: float) -> float:
+    """Return the softmax-weighted mean a finite ``alpha`` is defined to produce."""
+    finite = np.isfinite(values)
+    scaled = alpha * values[finite]
+    weights = np.exp(scaled - np.nanmax(scaled))
+    return float(np.average(values[finite], weights=weights))
+
+
+def two_positional_recorder(calls: list) -> callable:
+    """Return a canonical two-positional generator that records every dispatch."""
+
+    def generator(dataframe, params):
+        calls.append((len(dataframe), dict(params)))
+        return LabelData(series=pd.Series(0.0, index=dataframe.index), indices=[], metrics={})
+
+    return generator
+
+
+def three_positional_recorder(calls: list) -> callable:
+    """Return a canonical three-positional generator that records the logger it receives."""
+
+    def generator(dataframe, params, logger):
+        calls.append((len(dataframe), dict(params), logger))
+        return LabelData(series=pd.Series(1.0, index=dataframe.index), indices=[], metrics={})
+
+    return generator
+
+
+def three_positional_with_default(dataframe, params, logger=None):
+    """A canonical three-positional generator whose ``logger`` carries a default."""
+    return LabelData(series=pd.Series(0.0, index=dataframe.index), indices=[], metrics={})
+
+
+# --- generator signatures the registry must reject ---------------------------------------
+
+
+def var_positional_generator(*args):
+    """``*args`` is rejected: the positional arity cannot be inspected."""
+
+
+def var_keyword_generator(**kwargs):
+    """``**kwargs`` is rejected: the generator could swallow the logger silently."""
+
+
+def keyword_only_logger_generator(dataframe, params, *, logger=None):
+    """A keyword-only ``logger`` cannot be filled positionally at dispatch."""
+
+
+def misnamed_third_generator(dataframe, params, log):
+    """A required third positional parameter that is not ``logger``."""
+
+
+def misnamed_optional_third_generator(dataframe, params, log=None):
+    """An optional third positional parameter that is not ``logger``."""
+
+
+def one_positional_generator(dataframe):
+    """Too few required positionals: the params mapping would never reach the generator."""
+
+
+def four_positional_generator(dataframe, params, logger, extra):
+    """Too many required positionals: dispatch could never supply the fourth."""
+
+
+class UtilsZigzagTest(QaTestCase):
+    def test_a_v_shaped_series_marks_its_two_turning_points(self):
+        result = zigzag_on(*V_LEGS)
+
+        self.assertEqual(result.indices, [0, 29])
+        # The direction names the pivot's role, not the leg that follows it: index 0 is the
+        # peak the fall was measured from (UP) and index 29 the trough (DOWN).
+        self.assertEqual(result.directions, [TrendDirection.UP, TrendDirection.DOWN])
+        self.assertNotIn(TrendDirection.NEUTRAL, result.directions)
+        np.testing.assert_allclose(
+            result.values_log,
+            [np.log(100.0 * 1.001), np.log(80.0 * 0.999)],
+            rtol=1e-12,
+            atol=0.0,
+        )
+
+    def test_three_turning_points_alternate_peak_trough_peak(self):
+        closes = price_path(*W_LEGS)
+
+        result = zigzag_result(closes)
+
+        self.assertEqual(result.indices, [0, 29, 69])
+        self.assertEqual(
+            result.directions,
+            [TrendDirection.UP, TrendDirection.DOWN, TrendDirection.UP],
+        )
+        # Each pivot lands exactly on the turning point of the leg it closes, not one
+        # candle late: the peaks are the tops of their legs and the trough their bottom.
+        # The legs share their endpoint, so leg k starts one candle before its own share.
+        starts = np.cumsum([0] + [candles for _, _, candles in W_LEGS])
+        fall = closes[: starts[1]]
+        rise = closes[starts[1] - 1 : starts[2]]
+        decline = closes[starts[2] - 1 : starts[3]]
+        self.assertEqual(float(fall[0]), 100.0)
+        self.assertEqual(float(rise[0]), float(fall[-1]))
+        self.assertEqual(float(decline[0]), float(rise[-1]))
+        self.assertEqual(result.indices[0], int(np.argmax(fall)))
+        self.assertEqual(result.indices[1], starts[1] - 1 + int(np.argmin(rise)))
+        self.assertEqual(result.indices[2], starts[2] - 1 + int(np.argmax(decline)))
+
+    def test_pivot_metrics_are_backfilled_from_the_next_pivot(self):
+        result = zigzag_on(*W_LEGS)
+        first, second, last = result.indices
+
+        # A leg is measurable only once the pivot closing it is known, so the final pivot
+        # carries no amplitude, speed or efficiency ratio.
+        self.assertTrue(np.isnan(result.amplitudes[-1]))
+        self.assertTrue(np.isnan(result.speeds[-1]))
+        self.assertTrue(np.isnan(result.efficiency_ratios[-1]))
+        self.assertTrue(np.isnan(result.volume_weighted_efficiency_ratios[-1]))
+        self.assertTrue(np.all(np.isfinite(result.amplitudes[:-1])))
+
+        fall = abs(np.log(80.0 * 0.999) - np.log(100.0 * 1.001))
+        rise = abs(np.log(110.0 * 1.001) - np.log(80.0 * 0.999))
+        self.assertEqual(result.amplitudes[0], fall)
+        self.assertEqual(result.amplitudes[1], rise)
+        # speed is amplitude per candle of the leg the pivot closes.
+        self.assertEqual(result.speeds[0], fall / (second - first))
+        self.assertEqual(result.speeds[1], rise / (last - second))
+        # Both legs are monotone, so the travelled path equals the net move.
+        self.assertEqual(result.efficiency_ratios[:2], [1.0, 1.0])
+        self.assertEqual(result.volume_weighted_efficiency_ratios[:2], [1.0, 1.0])
+        # Constant volume makes the average volume per candle equal its median.
+        self.assertEqual(result.volume_rates[:2], [1.0, 1.0])
+        self.assertTrue(np.all(np.asarray(result.amplitude_threshold_ratios[:-1]) > 0.0))
+
+    def test_the_volume_rate_compares_the_leg_average_to_its_median(self):
+        closes = price_path(*W_LEGS)
+        # A volume that rises and falls inside every leg, so the average and the median of a
+        # leg differ and the ratio is not the identity.
+        volumes = 10.0 + 5.0 * np.sin(np.arange(len(closes)) / 4.0)
+
+        result = _zigzag(ohlcv_frame(closes, volumes), **PARAMS)
+
+        self.assertEqual(result.indices, [0, 29, 69])
+        for index in (0, 1):
+            window = volumes[result.indices[index] : result.indices[index + 1] + 1]
+            with self.subTest(pivot=result.indices[index]):
+                self.assertEqual(
+                    result.volume_rates[index], float(window.mean() / np.median(window))
+                )
+        self.assertNotEqual(result.volume_rates[0], 1.0)
+
+    def test_the_efficiency_ratio_measures_directness_of_the_leg(self):
+        closes = wiggly_closes()
+        result = zigzag_result(closes)
+
+        self.assertEqual(result.indices, [0, 29, 69])
+        log_closes = np.log(closes)
+        for index, previous in ((0, 0), (1, 29)):
+            # The ratio is the net move over the whole travelled path, so a leg containing a
+            # counter-trend dip scores strictly below the monotone case.
+            start, end = previous, result.indices[index + 1]
+            path = float(np.abs(np.diff(log_closes[start : end + 1])).sum())
+            net = abs(float(log_closes[end] - log_closes[start]))
+            with self.subTest(pivot=result.indices[index]):
+                np.testing.assert_allclose(
+                    result.efficiency_ratios[index], net / path, rtol=1e-12, atol=0.0
+                )
+        self.assertEqual(result.efficiency_ratios[0], 1.0)
+        self.assertLess(result.efficiency_ratios[1], 1.0)
+
+    def test_the_public_tuple_mirrors_the_result_fields(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+
+        result = _zigzag(frame, **PARAMS)
+
+        published = result.as_tuple()
+        self.assertEqual(
+            published,
+            (
+                result.indices,
+                result.values_log,
+                result.directions,
+                result.amplitudes,
+                result.amplitude_threshold_ratios,
+                result.volume_rates,
+                result.speeds,
+                result.efficiency_ratios,
+                result.volume_weighted_efficiency_ratios,
+            ),
+        )
+        # known_at_positions stays outside the tuple: it is a per-row array rather than a
+        # per-pivot metric, and the tuple is the per-pivot channel.
+        self.assertEqual(len(published), 9)
+        self.assertEqual(zigzag(frame, **PARAMS), published)
+
+    def test_a_rising_series_starts_from_a_trough_and_a_falling_one_from_a_peak(self):
+        rising = zigzag_on((100.0, 200.0, 60))
+        falling = zigzag_on((200.0, 100.0, 60))
+
+        # The first significant move is what orients the scan, so the seed pivot is the
+        # opposite extreme: a rise is confirmed from the trough, a fall from the peak.
+        self.assertEqual(rising.indices, [0])
+        self.assertEqual(rising.directions, [TrendDirection.DOWN])
+        self.assertEqual(falling.indices, [0])
+        self.assertEqual(falling.directions, [TrendDirection.UP])
+
+    def test_a_flat_series_never_produces_a_pivot(self):
+        flat = zigzag_on((100.0, 100.0, 60))
+
+        self.assertEqual(flat.indices, [])
+        self.assertEqual(flat.directions, [])
+        self.assertEqual(flat.amplitudes, [])
+        # NEUTRAL is the pre-orientation sentinel only: with no price move at all no pivot
+        # is ever confirmed, so no NEUTRAL label is ever emitted.
+        self.assertEqual(TrendDirection.NEUTRAL, 0)
+        self.assertEqual(TrendDirection.UP, 1)
+        self.assertEqual(TrendDirection.DOWN, -1)
+
+    def test_a_short_series_yields_no_pivot_but_a_full_known_at_row(self):
+        frame = ohlcv_frame(price_path((100.0, 90.0, 3)))
+
+        result = _zigzag(frame, **PARAMS)
+
+        self.assertEqual(result.indices, [])
+        np.testing.assert_array_equal(result.known_at_positions, np.full(3, 3, dtype=np.int64))
+
+    def test_a_single_candle_wobble_does_not_confirm_a_reversal(self):
+        # One candle down 12.5 % that recovers above the peak within three candles. The dip
+        # clears the amplitude threshold by a wide margin, so only the slope test refuses it.
+        wobble = zigzag_on(*RISE_TO_PEAK, (140.0, 165.0, 4), (165.0, 180.0, 15))
+
+        self.assertEqual(wobble.indices, [0])
+        self.assertEqual(wobble.directions, [TrendDirection.DOWN])
+
+    def test_a_sustained_reversal_is_confirmed_and_labels_the_peak(self):
+        sustained = zigzag_on(*RISE_TO_PEAK, (160.0, 120.0, 16), (120.0, 180.0, 16))
+
+        self.assertEqual(sustained.indices, [0, 44, 60])
+        self.assertEqual(
+            sustained.directions,
+            [TrendDirection.DOWN, TrendDirection.UP, TrendDirection.DOWN],
+        )
+
+    def test_the_confirmation_boundary_is_the_binomial_minimum(self):
+        # The test is a one-sided Binomial(0.5): with every slope in agreement the p-value
+        # is 2**-m, so m slopes confirm exactly when 2**-m <= alpha.
+        self.assertEqual(_ZIGZAG_CONFIRMATION_ALPHA, 0.05)
+        self.assertLessEqual(2.0**-_ZIGZAG_MIN_CONFIRMATION_SLOPES, _ZIGZAG_CONFIRMATION_ALPHA)
+        self.assertGreater(2.0 ** (1 - _ZIGZAG_MIN_CONFIRMATION_SLOPES), _ZIGZAG_CONFIRMATION_ALPHA)
+        self.assertEqual(_ZIGZAG_MIN_CONFIRMATION_SLOPES, 5)
+
+        rise = price_path(*RISE_TO_PEAK)
+        for declining_candles in range(1, _ZIGZAG_MIN_CONFIRMATION_SLOPES):
+            tail = np.linspace(160.0, 160.0 - 1.5 * declining_candles, declining_candles + 1)[1:]
+            with self.subTest(declining_candles=declining_candles):
+                self.assertEqual(zigzag_result(np.concatenate([rise, tail])).indices, [0])
+
+        confirmed_candles = _ZIGZAG_MIN_CONFIRMATION_SLOPES
+        tail = np.linspace(160.0, 160.0 - 1.5 * confirmed_candles, confirmed_candles + 1)[1:]
+        confirmed = zigzag_result(np.concatenate([rise, tail]))
+        self.assertEqual(confirmed.indices, [0, 44])
+        self.assertEqual(confirmed.directions, [TrendDirection.DOWN, TrendDirection.UP])
+
+    def test_a_decline_below_the_amplitude_threshold_is_not_confirmed(self):
+        rise = price_path(*RISE_TO_PEAK)
+        # Both dips run eight declining candles, well past the five-slope minimum, so only
+        # the amplitude gate separates them: a 1.5 % fall is inside the NATR threshold and a
+        # 15 % fall is outside it.
+        shallow = 160.0 - 0.3 * np.arange(1, 9)
+        deep = 160.0 - 3.0 * np.arange(1, 9)
+
+        refused = np.concatenate([rise, shallow, np.linspace(shallow[-1], 175.0, 14)])
+        confirmed = np.concatenate([rise, deep, np.linspace(deep[-1], 175.0, 14)])
+
+        self.assertEqual(zigzag_result(refused).indices, [0])
+        self.assertEqual(zigzag_result(confirmed).indices[:2], [0, 44])
+
+    def test_a_rebound_below_the_amplitude_threshold_is_not_confirmed(self):
+        # The mirror of the decline case: eight rising candles off the confirmed trough at
+        # index 60 clear the slope minimum but stay inside the NATR amplitude threshold.
+        approach = price_path(*PEAK_AND_TROUGH)
+        shallow = 120.0 + 0.4 * np.arange(1, 9)
+        deep = 120.0 + 1.0 * np.arange(1, 9)
+
+        refused = np.concatenate([approach, shallow, np.linspace(shallow[-1], 100.0, 14)])
+        confirmed = np.concatenate([approach, deep, np.linspace(deep[-1], 100.0, 14)])
+
+        self.assertEqual(zigzag_result(refused).indices, [0, 44])
+        self.assertEqual(zigzag_result(confirmed).indices, [0, 44, 60])
+
+    def test_the_first_clear_initial_threshold_orients_the_scan(self):
+        # Only one of the two thresholds is cleared at a time here, so the seed pivot is the
+        # extreme that the clearing move started from.
+        after_rise = zigzag_on(*DIP_AFTER_RISE)
+        after_fall = zigzag_on(*RISE_AFTER_FALL)
+
+        self.assertEqual(after_rise.indices, [0, 19, 25])
+        self.assertEqual(
+            after_rise.directions, [TrendDirection.DOWN, TrendDirection.UP, TrendDirection.DOWN]
+        )
+        self.assertEqual(after_fall.indices, [0, 19, 25])
+        self.assertEqual(
+            after_fall.directions, [TrendDirection.UP, TrendDirection.DOWN, TrendDirection.UP]
+        )
+
+    def test_the_larger_of_two_simultaneous_moves_orients_the_scan(self):
+        # Two nested wide bars clear the high threshold and the low threshold on the same
+        # candle without setting a new high or a new low, so the scan compares the two moves
+        # instead of taking whichever it happens to test first.
+        peak_first = _zigzag(nested_bar_frame(99.5, 0.0950), **PARAMS)
+        trough_first = _zigzag(nested_bar_frame(100.0, 0.0905), **PARAMS)
+
+        self.assertEqual(peak_first.indices[0], 0)
+        self.assertEqual(peak_first.directions[0], TrendDirection.UP)
+        self.assertEqual(trough_first.indices[0], 0)
+        self.assertEqual(trough_first.directions[0], TrendDirection.DOWN)
+
+    def test_the_label_series_is_aligned_to_the_input_index(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+
+        label = generate_label_data(frame, EXTREMA_COLUMN, PARAMS, LOGGER)
+
+        self.assertTrue(label.series.index.equals(frame.index))
+        self.assertEqual(label.series.dtype, np.float64)
+        self.assertEqual(sorted(set(label.series.tolist())), [-1.0, 0.0, 1.0])
+        self.assertEqual(label.indices, [0, 29, 69])
+        for position, direction in zip(label.indices, [1.0, -1.0, 1.0], strict=True):
+            self.assertEqual(label.series.loc[position], direction)
+        self.assertEqual(int((label.series != 0.0).sum()), len(label.indices))
+
+    def test_the_label_metrics_mirror_the_pivot_metrics(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+        result = _zigzag(frame, **PARAMS)
+
+        label = generate_label_data(frame, EXTREMA_COLUMN, PARAMS, LOGGER)
+
+        self.assertEqual(list(label.metrics), list(METRIC_FIELDS))
+        for name, field in METRIC_FIELDS.items():
+            with self.subTest(metric=name):
+                self.assertEqual(len(label.metrics[name]), len(label.indices))
+                np.testing.assert_allclose(
+                    label.metrics[name], getattr(result, field), equal_nan=True
+                )
+
+    def test_the_label_lookahead_is_the_confirmation_distance(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+        result = _zigzag(frame, **PARAMS)
+
+        label = generate_label_data(frame, EXTREMA_COLUMN, PARAMS, LOGGER)
+
+        lookahead = label.known_at_lookahead
+        self.assertIsNotNone(lookahead)
+        self.assertTrue(lookahead.index.equals(frame.index))
+        self.assertEqual(lookahead.dtype, np.int64)
+        # The lookahead is a distance in candles, not an absolute position.
+        np.testing.assert_array_equal(
+            lookahead.to_numpy(),
+            result.known_at_positions - np.arange(len(frame), dtype=np.int64),
+        )
+        self.assertTrue(np.all(np.diff(result.known_at_positions) >= 0))
+        # Each pivot stamps its confirmation position over the rows it resolves, and never
+        # over rows a later pivot has already stamped: 5 for the orientation candle (the
+        # ATR warm-up, which no earlier confirmation could undercut), then 37, 84 and
+        # finally n for the tail the scan never resolved.
+        n = len(frame)
+        expected = np.concatenate(
+            [
+                np.full(1, 5),
+                np.full(37, 37),
+                np.full(47, 84),
+                np.full(n - 85, n),
+            ]
+        )
+        self.assertEqual(n, 101)
+        np.testing.assert_array_equal(result.known_at_positions, expected)
+        # Every row is resolved no earlier than itself, and the unresolved tail, marked by
+        # known_at == n, still has to burn off one candle of lookahead per row.
+        self.assertTrue(np.all(result.known_at_positions >= np.arange(len(frame), dtype=np.int64)))
+        unresolved = result.known_at_positions == len(frame)
+        self.assertGreaterEqual(int(unresolved.sum()), 2)
+        self.assertTrue(np.all(lookahead.to_numpy()[unresolved] > 0))
+        self.assertEqual(
+            np.diff(lookahead.to_numpy()[unresolved]).tolist(),
+            [-1] * (int(unresolved.sum()) - 1),
+        )
+
+    def test_an_unregistered_label_column_raises_a_key_error_listing_the_registry(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+
+        self.assertEqual(LABEL_COLUMNS, (EXTREMA_COLUMN,))
+        self.assertIn(EXTREMA_COLUMN, _LABEL_GENERATORS)
+
+        with self.assertRaises(KeyError) as caught:
+            generate_label_data(frame, "&s-absent", PARAMS, LOGGER)
+
+        message = str(caught.exception)
+        self.assertIn("No label generator registered for column '&s-absent'", message)
+        self.assertIn(f"Available columns: {[EXTREMA_COLUMN]}", message)
+
+    def test_a_two_positional_generator_round_trips_and_never_sees_the_logger(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+        calls: list = []
+        self.addCleanup(_LABEL_GENERATORS.pop, FAKE_LABEL_COLUMN, None)
+        register_label_generator(FAKE_LABEL_COLUMN, two_positional_recorder(calls))
+
+        label = generate_label_data(frame, FAKE_LABEL_COLUMN, PARAMS, LOGGER)
+
+        # Dispatch always passes three arguments; the adapter is what drops the logger.
+        self.assertEqual(calls, [(len(frame), PARAMS)])
+        self.assertTrue(label.series.index.equals(frame.index))
+
+    def test_the_label_generator_registry_is_restored_after_every_case(self):
+        # A second case is run to completion inside this one, so the restore is observed
+        # without depending on the order unittest happens to run the methods in.
+        class RegisteringCase(QaTestCase):
+            def runTest(self):
+                register_label_generator(FAKE_LABEL_COLUMN, two_positional_recorder([]))
+                self.assertIn(FAKE_LABEL_COLUMN, _LABEL_GENERATORS)
+
+        outcome = RegisteringCase().run()
+
+        self.assertTrue(outcome.wasSuccessful())
+        self.assertNotIn(FAKE_LABEL_COLUMN, _LABEL_GENERATORS)
+        self.assertEqual(list(_LABEL_GENERATORS), [EXTREMA_COLUMN])
+
+    def test_the_two_canonical_generator_shapes_are_accepted(self):
+        calls: list = []
+        two = two_positional_recorder(calls)
+        three = three_positional_recorder([])
+        frame = ohlcv_frame(price_path(*W_LEGS))
+
+        # Two positionals are wrapped so dispatch can still pass three arguments; three
+        # positionals named logger pass through untouched, with or without a default.
+        adapted = _adapt_label_generator(two)
+        self.assertIsNot(adapted, two)
+        adapted(frame, PARAMS, LOGGER)
+        self.assertEqual(calls, [(len(frame), PARAMS)])
+        self.assertIs(_adapt_label_generator(three), three)
+        self.assertIs(
+            _adapt_label_generator(three_positional_with_default),
+            three_positional_with_default,
+        )
+
+    def test_a_three_positional_generator_receives_the_logger(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+        calls: list = []
+        self.addCleanup(_LABEL_GENERATORS.pop, FAKE_LABEL_COLUMN, None)
+        register_label_generator(FAKE_LABEL_COLUMN, three_positional_recorder(calls))
+
+        label = generate_label_data(frame, FAKE_LABEL_COLUMN, PARAMS, LOGGER)
+
+        self.assertEqual(calls, [(len(frame), PARAMS, LOGGER)])
+        self.assertEqual(set(label.series.tolist()), {1.0})
+
+    def test_every_other_generator_signature_is_rejected(self):
+        rejected = (
+            (var_positional_generator, "*args"),
+            (var_keyword_generator, "**kwargs"),
+            (keyword_only_logger_generator, "keyword-only ``logger`` is not supported"),
+            (misnamed_third_generator, "third positional parameter is named 'log'"),
+            (misnamed_optional_third_generator, "third positional parameter is named 'log'"),
+            (one_positional_generator, "1 required positional parameter(s); expected at least 2"),
+            (four_positional_generator, "4 required positional parameter(s); expected 2"),
+        )
+        for generator, reason in rejected:
+            with self.subTest(generator=generator.__name__):
+                with self.assertRaises(ValueError) as caught:
+                    _adapt_label_generator(generator)
+                self.assertIn(reason, str(caught.exception))
+
+    def test_a_rejected_generator_is_never_registered(self):
+        frame = ohlcv_frame(price_path(*W_LEGS))
+
+        with self.assertRaises(ValueError):
+            register_label_generator(FAKE_LABEL_COLUMN, misnamed_third_generator)
+
+        self.assertNotIn(FAKE_LABEL_COLUMN, _LABEL_GENERATORS)
+        with self.assertRaises(KeyError):
+            generate_label_data(frame, FAKE_LABEL_COLUMN, PARAMS, LOGGER)
+
+    def test_alpha_zero_returns_the_plain_mean(self):
+        values = np.array([1.0, 2.0, 3.0, 9.0])
+
+        self.assertEqual(soft_extremum(pd.Series(values), 0.0), float(np.mean(values)))
+
+    def test_the_plain_mean_skips_nan_but_keeps_infinities(self):
+        # The alpha-0 branch calls np.nanmean directly, so - unlike the weighted branch,
+        # which runs through nan_average's finite mask - it does not strip +/-inf.
+        self.assertEqual(soft_extremum(pd.Series([1.0, np.nan, 3.0, 5.0]), 0.0), 3.0)
+        self.assertEqual(soft_extremum(pd.Series([1.0, np.inf]), 0.0), np.inf)
+
+    def test_a_negligible_alpha_is_treated_as_zero(self):
+        # The gate is np.isclose(alpha, 0.0), so an alpha far below its tolerance disables
+        # the softmax outright instead of merely flattening it.
+        values = np.array([1.0, 2.0, 3.0, 9.0])
+        plain = float(np.mean(values))
+
+        for alpha in (0.0, 1e-12, -1e-12, 1e-9):
+            with self.subTest(alpha=alpha):
+                self.assertEqual(soft_extremum(pd.Series(values), alpha), plain)
+
+    def test_a_finite_alpha_returns_the_softmax_weighted_mean(self):
+        values = np.array([1.0, 2.0, 3.0, 4.0])
+        series = pd.Series(values)
+
+        for alpha in (-12.0, -1.0, 0.5, 1.0, 3.0, 12.0):
+            with self.subTest(alpha=alpha):
+                np.testing.assert_allclose(
+                    soft_extremum(series, alpha),
+                    soft_extremum_oracle(values, alpha),
+                    rtol=1e-12,
+                    atol=0.0,
+                )
+
+        # The sign of alpha picks which end is favoured, and the magnitude concentrates the
+        # weight: a soft minimum sits below the mean, a soft maximum above it.
+        self.assertLess(soft_extremum(series, -12.0), soft_extremum(series, -1.0))
+        self.assertLess(soft_extremum(series, 1.0), soft_extremum(series, 12.0))
+        self.assertLess(soft_extremum(series, -12.0), float(np.mean(values)))
+        self.assertGreater(soft_extremum(series, 12.0), float(np.mean(values)))
+
+    def test_the_weighted_mean_ignores_missing_values(self):
+        values = np.array([1.0, np.nan, 3.0, 5.0])
+
+        np.testing.assert_allclose(
+            soft_extremum(pd.Series(values), 1.0),
+            soft_extremum_oracle(values, 1.0),
+            rtol=1e-12,
+            atol=0.0,
+        )
+
+    def test_empty_or_all_non_finite_input_returns_nan(self):
+        for values in ([], [np.nan], [np.nan, np.nan, np.nan], [np.inf, -np.inf]):
+            for alpha in (0.0, 1.0, 12.0):
+                with self.subTest(values=values, alpha=alpha):
+                    self.assertTrue(np.isnan(soft_extremum(pd.Series(values, dtype=float), alpha)))
+
+    def test_a_non_finite_softmax_scale_falls_back_to_the_argmax_element(self):
+        # alpha * 2 overflows, so the shift constant is infinite and no softmax can be
+        # formed; the fallback is the element the scaled series maximises.
+        with np.errstate(over="ignore"):
+            self.assertEqual(soft_extremum(pd.Series([1.0, 2.0, 0.5]), 1e308), 2.0)
+            self.assertEqual(soft_extremum(pd.Series([-5.0, 2.0, 0.5]), 1e308), 2.0)
+            # Every scaled entry is +inf here, so the argmax is the first position.
+            self.assertEqual(soft_extremum(pd.Series([1.0, 2.0, 0.5]), np.inf), 1.0)
+
+
+if __name__ == "__main__":
+    unittest.main()