From 94d406c4b6e6a0f0ba5ab8330e3d43259fccde64 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 1 Oct 2026 13:00:08 +0200 Subject: [PATCH] test(quickadapter): cover the strategy business logic and gate coverage at 67% (#279) 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. --- .github/workflows/quality.yml | 25 +- README.md | 56 +- openspec/config.yaml | 3 +- quickadapter/.coveragerc | 12 + .../.devcontainer/requirements-dev.txt | 1 + quickadapter/tests/qa_support.py | 131 + quickadapter/tests/test_coverage_floor.py | 90 + quickadapter/tests/test_enum_errors.py | 43 + quickadapter/tests/test_label_transformer.py | 349 +++ quickadapter/tests/test_prediction_history.py | 43 +- .../tests/test_regressor_data_split.py | 418 ++++ .../tests/test_regressor_label_config.py | 971 ++++++++ .../tests/test_regressor_optuna_study.py | 1067 +++++++++ .../tests/test_regressor_prediction_dedup.py | 329 +++ .../test_regressor_prediction_history.py | 851 +++++++ .../tests/test_regressor_selection.py | 2109 +++++++++++++++++ .../tests/test_regressor_weight_support.py | 216 ++ quickadapter/tests/test_strategy_features.py | 694 ++++++ quickadapter/tests/test_strategy_pure.py | 295 +++ quickadapter/tests/test_strategy_reversal.py | 312 +++ quickadapter/tests/test_strategy_sizing.py | 310 +++ quickadapter/tests/test_strategy_targets.py | 628 +++++ quickadapter/tests/test_suite_contract.py | 64 + .../tests/test_utils_canonical_defaults.py | 432 ++++ .../tests/test_utils_config_migration.py | 250 ++ .../tests/test_utils_config_resolution.py | 688 ++++++ quickadapter/tests/test_utils_formatting.py | 405 ++++ quickadapter/tests/test_utils_indicators.py | 762 ++++++ .../tests/test_utils_label_weights.py | 224 ++ .../tests/test_utils_numeric_safety.py | 182 ++ .../tests/test_utils_regressor_plumbing.py | 493 ++++ quickadapter/tests/test_utils_step_math.py | 122 + .../tests/test_utils_validation_algebra.py | 296 +++ quickadapter/tests/test_utils_zigzag.py | 644 +++++ 34 files changed, 13470 insertions(+), 45 deletions(-) create mode 100644 quickadapter/.coveragerc create mode 100644 quickadapter/tests/qa_support.py create mode 100644 quickadapter/tests/test_coverage_floor.py create mode 100644 quickadapter/tests/test_enum_errors.py create mode 100644 quickadapter/tests/test_label_transformer.py create mode 100644 quickadapter/tests/test_regressor_data_split.py create mode 100644 quickadapter/tests/test_regressor_label_config.py create mode 100644 quickadapter/tests/test_regressor_optuna_study.py create mode 100644 quickadapter/tests/test_regressor_prediction_dedup.py create mode 100644 quickadapter/tests/test_regressor_prediction_history.py create mode 100644 quickadapter/tests/test_regressor_selection.py create mode 100644 quickadapter/tests/test_regressor_weight_support.py create mode 100644 quickadapter/tests/test_strategy_features.py create mode 100644 quickadapter/tests/test_strategy_pure.py create mode 100644 quickadapter/tests/test_strategy_reversal.py create mode 100644 quickadapter/tests/test_strategy_sizing.py create mode 100644 quickadapter/tests/test_strategy_targets.py create mode 100644 quickadapter/tests/test_suite_contract.py create mode 100644 quickadapter/tests/test_utils_canonical_defaults.py create mode 100644 quickadapter/tests/test_utils_config_migration.py create mode 100644 quickadapter/tests/test_utils_config_resolution.py create mode 100644 quickadapter/tests/test_utils_formatting.py create mode 100644 quickadapter/tests/test_utils_indicators.py create mode 100644 quickadapter/tests/test_utils_label_weights.py create mode 100644 quickadapter/tests/test_utils_numeric_safety.py create mode 100644 quickadapter/tests/test_utils_regressor_plumbing.py create mode 100644 quickadapter/tests/test_utils_step_math.py create mode 100644 quickadapter/tests/test_utils_validation_algebra.py create mode 100644 quickadapter/tests/test_utils_zigzag.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1f6fdef..51075a6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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 diff --git a/README.md b/README.md index b194c3c..1366f85 100644 --- 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 diff --git a/openspec/config.yaml b/openspec/config.yaml index 959fbeb..c8bf2b9 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -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 index 0000000..2966e87 --- /dev/null +++ b/quickadapter/.coveragerc @@ -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 diff --git a/quickadapter/.devcontainer/requirements-dev.txt b/quickadapter/.devcontainer/requirements-dev.txt index 685a876..cdffe81 100644 --- a/quickadapter/.devcontainer/requirements-dev.txt +++ b/quickadapter/.devcontainer/requirements-dev.txt @@ -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 index 0000000..07c37e7 --- /dev/null +++ b/quickadapter/tests/qa_support.py @@ -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 index 0000000..01d7d24 --- /dev/null +++ b/quickadapter/tests/test_coverage_floor.py @@ -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 index 0000000..31bb7ba --- /dev/null +++ b/quickadapter/tests/test_enum_errors.py @@ -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 index 0000000..e9f0ddf --- /dev/null +++ b/quickadapter/tests/test_label_transformer.py @@ -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() diff --git a/quickadapter/tests/test_prediction_history.py b/quickadapter/tests/test_prediction_history.py index 2965d8e..cb54022 100644 --- a/quickadapter/tests/test_prediction_history.py +++ b/quickadapter/tests/test_prediction_history.py @@ -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 index 0000000..60217c2 --- /dev/null +++ b/quickadapter/tests/test_regressor_data_split.py @@ -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 index 0000000..caa38c4 --- /dev/null +++ b/quickadapter/tests/test_regressor_label_config.py @@ -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 index 0000000..f50a851 --- /dev/null +++ b/quickadapter/tests/test_regressor_optuna_study.py @@ -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 index 0000000..043c5f2 --- /dev/null +++ b/quickadapter/tests/test_regressor_prediction_dedup.py @@ -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 index 0000000..6c58b4f --- /dev/null +++ b/quickadapter/tests/test_regressor_prediction_history.py @@ -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 index 0000000..e463c15 --- /dev/null +++ b/quickadapter/tests/test_regressor_selection.py @@ -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 index 0000000..7ff08b8 --- /dev/null +++ b/quickadapter/tests/test_regressor_weight_support.py @@ -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 index 0000000..b19ded9 --- /dev/null +++ b/quickadapter/tests/test_strategy_features.py @@ -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 index 0000000..5bfea67 --- /dev/null +++ b/quickadapter/tests/test_strategy_pure.py @@ -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 index 0000000..3fd8cb4 --- /dev/null +++ b/quickadapter/tests/test_strategy_reversal.py @@ -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 index 0000000..4f09989 --- /dev/null +++ b/quickadapter/tests/test_strategy_sizing.py @@ -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 index 0000000..2777987 --- /dev/null +++ b/quickadapter/tests/test_strategy_targets.py @@ -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 index 0000000..86d3a82 --- /dev/null +++ b/quickadapter/tests/test_suite_contract.py @@ -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 index 0000000..9f25deb --- /dev/null +++ b/quickadapter/tests/test_utils_canonical_defaults.py @@ -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 index 0000000..b1c16b7 --- /dev/null +++ b/quickadapter/tests/test_utils_config_migration.py @@ -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 index 0000000..18b0cfb --- /dev/null +++ b/quickadapter/tests/test_utils_config_resolution.py @@ -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 index 0000000..a75b981 --- /dev/null +++ b/quickadapter/tests/test_utils_formatting.py @@ -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: {}}}", format_dict(root) + ) + # Wrapped one level deeper the same cycle is found one level earlier in the expansion. + self.assertEqual("{root: {name: 'root', self: {}}}", format_dict({"root": root})) + + def test_a_self_referential_sequence_terminates_as_circular(self): + cyclic = [1] + cyclic.append(cyclic) + self.assertEqual("{l: [1, []]}", format_dict({"l": cyclic})) + + holder = [1] + pair = (holder,) + holder.append(pair) + self.assertEqual("{l: [1, ([],)]}", format_dict({"l": holder})) + + mapping = {"a": {}} + mapping["a"]["back"] = mapping + self.assertEqual("{a: {back: {a: {}}}}", 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 "" + + def __str__(self) -> str: + return "" + + # repr, not str: the two disagree here so a str fallback would render the other form. + self.assertEqual("{a: }", 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 index 0000000..2d8bbff --- /dev/null +++ b/quickadapter/tests/test_utils_indicators.py @@ -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 index 0000000..93ecc59 --- /dev/null +++ b/quickadapter/tests/test_utils_label_weights.py @@ -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 index 0000000..0acbde7 --- /dev/null +++ b/quickadapter/tests/test_utils_numeric_safety.py @@ -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 index 0000000..84205d1 --- /dev/null +++ b/quickadapter/tests/test_utils_regressor_plumbing.py @@ -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 index 0000000..c0d1a4c --- /dev/null +++ b/quickadapter/tests/test_utils_step_math.py @@ -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 index 0000000..5a18550 --- /dev/null +++ b/quickadapter/tests/test_utils_validation_algebra.py @@ -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 index 0000000..98c3ba9 --- /dev/null +++ b/quickadapter/tests/test_utils_zigzag.py @@ -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() -- 2.53.0