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.
strategy-qa:
name: QA (${{ matrix.name }})
runs-on: ubuntu-latest
- timeout-minutes: 30
+ timeout-minutes: 45
strategy:
fail-fast: false
matrix:
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
/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" \
--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
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
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
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:
--- /dev/null
+[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
uv
ruff
basedpyright==1.40.1
+coverage==7.16.2
--- /dev/null
+"""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)
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
"""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
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
)
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
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 (
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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",
+ )
--- /dev/null
+"""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,
+ )
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""Log formatting of scalars and structures; requires the Freqtrade QA image."""
+
+import os
+import subprocess
+import sys
+import unittest
+
+import numpy as np
+from qa_support import QaTestCase
+from Utils import (
+ _format_value,
+ _FormatContext,
+ format_dict,
+ format_number,
+)
+
+
+def _ctx(quote_strings: bool = True, sig_digits: int = 5) -> _FormatContext:
+ return _FormatContext(quote_strings=quote_strings, sig_digits=sig_digits)
+
+
+class UtilsFormattingTest(QaTestCase):
+ def test_non_finite_scalars_render_as_symbols(self):
+ for value, expected in (
+ (float("inf"), "+∞"),
+ (float("-inf"), "-∞"),
+ (float("nan"), "NaN"),
+ (np.float64("inf"), "+∞"),
+ (np.float32("-inf"), "-∞"),
+ (np.float32("nan"), "NaN"),
+ ):
+ with self.subTest(value=repr(value)):
+ self.assertEqual(expected, format_number(value))
+ self.assertEqual(
+ "{p: +∞, n: -∞, x: NaN}",
+ format_dict({"p": float("inf"), "n": float("-inf"), "x": float("nan")}),
+ )
+
+ def test_zero_renders_without_a_decimal_point(self):
+ for value in (0, 0.0, -0.0, np.float64(0.0), np.int64(0)):
+ with self.subTest(value=repr(value)):
+ self.assertEqual("0", format_number(value))
+ self.assertEqual("{a: 0, b: 0}", format_dict({"a": 0, "b": -0.0}))
+
+ def test_scientific_notation_is_used_outside_the_fixed_range(self):
+ # The bounds are inclusive on the outside: |v| >= 1e12 and 0 < |v| <= 1e-6 switch over,
+ # so 1e-6 is scientific while the next float above it is fixed.
+ scientific = (1e-6, 9.99e-7, 1e-7, 5e-324, 1e12, 1.5e12, 1e20, -1e-6, -1e12, -5e-324)
+ fixed = (1.0000001e-6, 1e-5, 0.1, 1.5, 12345.6789, 1e11, 999_999_999_999.0, -1e12 + 1.0)
+ for value in scientific:
+ with self.subTest(value=repr(value), expected="scientific"):
+ self.assertIn("e", format_number(value))
+ for value in fixed:
+ with self.subTest(value=repr(value), expected="fixed"):
+ self.assertNotIn("e", format_number(value))
+
+ def test_fixed_notation_strips_trailing_zeros(self):
+ for value, expected in (
+ (2.0, "2"),
+ (1.0, "1"),
+ (100.0, "100"),
+ (1.5, "1.5"),
+ (0.1, "0.1"),
+ (-1.5, "-1.5"),
+ (3.14159265, "3.1416"),
+ (1.23456789, "1.2346"),
+ ):
+ with self.subTest(value=repr(value)):
+ self.assertEqual(expected, format_number(value))
+ # A value whose trailing digits round to zero loses them and its decimal point.
+ for value, expected in ((2.50, "2.5"), (1.10, "1.1"), (0.10, "0.1"), (123.0, "123")):
+ with self.subTest(value=repr(value)):
+ self.assertEqual(expected, format_number(value))
+
+ def test_the_fixed_branch_counts_significant_digits_not_decimal_places(self):
+ # The decimal places come from the magnitude, so the same request keeps the digit count
+ # as the value grows: five significant digits, whether or not a decimal point appears.
+ for value, expected in (
+ (1.23456789, "1.2346"),
+ (12.3456789, "12.346"),
+ (123.456789, "123.46"),
+ (1234.56789, "1234.6"),
+ (12345.6789, "12346"),
+ (-12345.6789, "-12346"),
+ (9999.99, "10000"),
+ ):
+ with self.subTest(value=repr(value)):
+ self.assertEqual(expected, format_number(value))
+ # Past five digits the precision goes negative and the value rounds to a whole number
+ # carrying more digits than were requested.
+ for value, expected in ((99999.9, "100000"), (123456.0, "123460"), (1234567.0, "1234600")):
+ with self.subTest(value=repr(value)):
+ self.assertEqual(expected, format_number(value))
+
+ def test_significant_digits_scale_the_output(self):
+ for digits, expected in ((2, "3.1"), (3, "3.14"), (5, "3.1416"), (8, "3.1415927")):
+ with self.subTest(digits=digits):
+ self.assertEqual(expected, format_number(3.14159265358979, digits))
+ # One digit fewer than the request goes to the exponent in the scientific branch.
+ self.assertEqual(f"{1e-7:.{digits - 1}e}", format_number(1e-7, digits))
+
+ def test_an_int_wider_than_a_numpy_integer_is_converted_before_formatting(self):
+ # format_number hands its argument to numpy, which cannot classify a Python int beyond
+ # 64 bits; the int registration converts to float first, so the structure still renders.
+ self.assertEqual("{z: 1.1806e+21}", format_dict({"z": 2**70}))
+
+ def test_significant_digits_reach_the_nested_values(self):
+ for digits, expected in (
+ (2, "{o: {y: 3.1}}"),
+ (3, "{o: {y: 3.14}}"),
+ (5, "{o: {y: 3.1416}}"),
+ ):
+ with self.subTest(digits=digits):
+ self.assertEqual(
+ expected, format_dict({"o": {"y": 3.14159265358979}}, significant_digits=digits)
+ )
+ # The request also reaches a sequence element and the scientific branch below it.
+ self.assertEqual(
+ "{o: [3.14]}", format_dict({"o": [3.14159265358979]}, significant_digits=3)
+ )
+ self.assertEqual(
+ "{o: {y: 1.00e-07}}", format_dict({"o": {"y": 1e-7}}, significant_digits=3)
+ )
+
+ def test_a_non_numeric_value_falls_back_to_its_str(self):
+ self.assertEqual("abc", format_number("abc"))
+ self.assertEqual("1.5", format_number(np.float64(1.5)))
+ self.assertEqual("7", format_number(np.int64(7)))
+
+ def test_bools_render_as_words_in_a_structure_and_as_digits_alone(self):
+ # bool is an int subclass, so the singledispatch registration is what keeps "True" out of
+ # format_number, which would render it as "1".
+ ctx = _ctx()
+ for value in (True, False, np.bool_(True), np.bool_(False)):
+ with self.subTest(value=repr(value)):
+ self.assertEqual(str(bool(value)), _format_value(value, ctx, 0))
+ self.assertEqual("1", format_number(True))
+ self.assertEqual("0", format_number(False))
+ self.assertEqual("{a: True, b: False}", format_dict({"a": True, "b": np.bool_(False)}))
+
+ def test_an_empty_mapping_renders_as_the_empty_brace_form_or_an_empty_string(self):
+ self.assertEqual("{}", format_dict({}))
+ self.assertEqual("", format_dict({}, style="params"))
+
+ def test_the_dict_style_quotes_strings_and_the_params_style_does_not(self):
+ payload = {
+ "a": 1,
+ "b": "txt",
+ "c": 1.5,
+ "d": True,
+ "e": None,
+ "f": [1, 2],
+ "g": (3,),
+ "h": {"k": 2},
+ }
+ self.assertEqual(
+ "{a: 1, b: 'txt', c: 1.5, d: True, e: None, f: [1, 2], g: (3,), h: {k: 2}}",
+ format_dict(payload),
+ )
+ self.assertEqual(
+ "a=1, b=txt, c=1.5, d=True, e=None, f=[1, 2], g=(3,), h={k=2}",
+ format_dict(payload, style="params"),
+ )
+
+ def test_keys_are_never_quoted_or_escaped(self):
+ # Only values go through _format_value, in either style and at any nesting depth.
+ self.assertEqual(
+ "{a b: 1, with'quote: 2, with=eq: 3}",
+ format_dict({"a b": 1, "with'quote": 2, "with=eq": 3}),
+ )
+ self.assertEqual("{a: {b'c: 1}}", format_dict({"a": {"b'c": 1}}))
+ self.assertEqual("k={a b=1}", format_dict({"k": {"a b": 1}}, style="params"))
+
+ def test_a_self_referential_mapping_terminates_as_circular(self):
+ root = {"name": "root"}
+ root["self"] = root
+ self.assertEqual(
+ "{name: 'root', self: {name: 'root', self: {<circular>}}}", format_dict(root)
+ )
+ # Wrapped one level deeper the same cycle is found one level earlier in the expansion.
+ self.assertEqual("{root: {name: 'root', self: {<circular>}}}", format_dict({"root": root}))
+
+ def test_a_self_referential_sequence_terminates_as_circular(self):
+ cyclic = [1]
+ cyclic.append(cyclic)
+ self.assertEqual("{l: [1, [<circular>]]}", format_dict({"l": cyclic}))
+
+ holder = [1]
+ pair = (holder,)
+ holder.append(pair)
+ self.assertEqual("{l: [1, ([<circular>],)]}", format_dict({"l": holder}))
+
+ mapping = {"a": {}}
+ mapping["a"]["back"] = mapping
+ self.assertEqual("{a: {back: {a: {<circular>}}}}", format_dict(mapping))
+
+ def test_the_seen_set_tracks_the_current_path_and_not_every_visited_object(self):
+ # ctx.seen is added on the way down and discarded on the way back up, so a repeated
+ # sibling reference is rendered in full twice while an ancestor reference is not.
+ shared = {"k": 1}
+ self.assertEqual("{a: {k: 1}, b: {k: 1}}", format_dict({"a": shared, "b": shared}))
+ # The same holds for the sequence path, which keeps its own add and discard.
+ shared_list = [1, 2]
+ self.assertEqual(
+ "{a: [1, 2], b: [1, 2]}", format_dict({"a": shared_list, "b": shared_list})
+ )
+ shared_set = {1, 2}
+ self.assertEqual("{a: {1, 2}, b: {1, 2}}", format_dict({"a": shared_set, "b": shared_set}))
+ self.assertEqual("{l: [[1, 2], [1, 2]]}", format_dict({"l": [shared_list, shared_list]}))
+ self.assertEqual(
+ "{a: [1, 2], b: {c: [1, 2]}}", format_dict({"a": shared_list, "b": {"c": shared_list}})
+ )
+ # A context reused across two renders gives the same answer both times, which is only
+ # true when the first render discarded the ids it added.
+ reused = _ctx()
+ self.assertEqual("[1, [1, 2]]", _format_value([1, shared_list], reused, 0))
+ self.assertEqual("[1, [1, 2]]", _format_value([1, shared_list], reused, 0))
+ self.assertEqual(set(), reused.seen)
+ # Identity, not equality, is what seen tracks: two distinct objects with equal contents
+ # render exactly as the single repeated object does.
+ self.assertEqual(
+ format_dict({"a": shared, "b": shared}), format_dict({"a": {"k": 1}, "b": {"k": 1}})
+ )
+ # A chain of distinct objects terminates on the depth cap, not on identity.
+ chain = current = {}
+ for index in range(6):
+ current["next"] = {"i": index}
+ current = current["next"]
+ self.assertEqual("{root: {next: {i: 0, next: {...}}}}", format_dict({"root": chain}))
+
+ def test_nesting_is_capped_at_depth_two(self):
+ self.assertEqual("{a: {b: {c: {...}}}}", format_dict({"a": {"b": {"c": {"d": 1}}}}))
+ self.assertEqual("{a: [[[...]]]}", format_dict({"a": [[["x"]]]}))
+ self.assertEqual("{a: (((...),),)}", format_dict({"a": (((1,),),)}))
+ # A mapping hits the cap before its emptiness check, a sequence after it, so an empty
+ # mapping is elided at depth two where an empty sequence is still spelled out.
+ self.assertEqual(
+ "{a: {b: [[], {...}, (), set()]}}", format_dict({"a": {"b": [[], {}, (), set()]}})
+ )
+ for depth in range(2, 5):
+ with self.subTest(depth=depth):
+ self.assertEqual("{...}", _format_value({"b": 1}, _ctx(), depth))
+ self.assertEqual("[...]", _format_value([1], _ctx(), depth))
+ self.assertEqual("(...)", _format_value((1,), _ctx(), depth))
+
+ def test_collections_are_capped_at_ten_items_with_an_overflow_count(self):
+ payload = {
+ "list": list(range(12)),
+ "tuple": tuple(range(12)),
+ "set": set(range(12)),
+ "dict": {f"k{i}": i for i in range(12)},
+ }
+ for kind, rendered in (
+ ("list", "{list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+2]}"),
+ ("tuple", "{tuple: (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+2)}"),
+ (
+ "dict",
+ "{dict: {k0: 0, k1: 1, k2: 2, k3: 3, k4: 4, k5: 5, k6: 6, k7: 7, k8: 8, k9: 9, ...+2}}",
+ ),
+ ):
+ with self.subTest(kind=kind):
+ self.assertEqual(rendered, format_dict({kind: payload[kind]}))
+ # The count is the overflow, not the total, and a full set of exactly ten items has none.
+ self.assertEqual(
+ "{s: {0, 1, 10, 2, 3, 4, 5, 6, 7, 8, ...+1}}", format_dict({"s": set(range(11))})
+ )
+ self.assertEqual("{l: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]}", format_dict({"l": list(range(10))}))
+ self.assertEqual(
+ "{l: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+1]}", format_dict({"l": list(range(11))})
+ )
+
+ def test_the_mapping_passed_to_format_dict_is_rendered_in_full(self):
+ # format_dict joins d.items() itself, so the ten-item cap reaches only nested mappings.
+ keys = [f"k{i}" for i in range(25)]
+ self.assertEqual(25, len(format_dict(dict.fromkeys(keys, 1)).split(", ")))
+ capped = format_dict({"k": list(range(15))})
+ self.assertEqual("{k: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...+5]}", capped)
+ self.assertEqual(10, len(capped.split(", ")) - 1)
+
+ def test_a_set_renders_its_items_in_string_order(self):
+ words = {"delta", "alpha", "charlie", "bravo", "echo", "foxtrot", "golf", "hotel", "india"}
+ self.assertEqual(
+ "{s: {'alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india'}}",
+ format_dict({"s": words}),
+ )
+ # String order, not numeric order: "10" precedes "2".
+ self.assertEqual(
+ "{s: {10, 2, 33, 4, 55, 6, 77, 8, 99}}",
+ format_dict({"s": {10, 2, 33, 4, 55, 6, 77, 8, 99}}),
+ )
+ self.assertEqual(
+ "{s: {(3, 4), 1, 2.5, 'a', 'b'}}", format_dict({"s": {"b", 1, "a", 2.5, (3, 4)}})
+ )
+ # The cap keeps the first ten in that order, which is what makes the overflow count stable.
+ twelve = {f"s{i}" for i in range(12)}
+ expected = ["s0", "s1", "s10", "s11", "s2", "s3", "s4", "s5", "s6", "s7"]
+ self.assertEqual(
+ "{s: {" + ", ".join(f"'{item}'" for item in expected) + ", ...+2}}",
+ format_dict({"s": twelve}),
+ )
+ self.assertEqual(expected, sorted(twelve, key=str)[:10])
+
+ def test_backslashes_and_control_characters_are_escaped(self):
+ raw = "a\\b\nc\rd\te"
+ quoted = format_dict({"s": raw})
+ self.assertEqual(r"{s: 'a\\b\nc\rd\te'}", quoted)
+ self.assertEqual(r"s=a\\b\nc\rd\te", format_dict({"s": raw}, style="params"))
+ for control in ("\n", "\r", "\t"):
+ with self.subTest(control=repr(control)):
+ self.assertNotIn(control, format_dict({"s": f"x{control}y"}))
+ self.assertEqual(1, format_dict({"s": f"x{control}y"}).count("\\"))
+
+ def test_a_single_quote_is_escaped_only_where_strings_are_quoted(self):
+ # The dict style wraps in quotes and so escapes the inner ones; params leaves both bare.
+ self.assertEqual(r"{s: 'it\'s a \'test\''}", format_dict({"s": "it's a 'test'"}))
+ self.assertEqual(r"{o: {s: 'it\'s'}}", format_dict({"o": {"s": "it's"}}))
+ self.assertEqual("s=it's a 'test'", format_dict({"s": "it's a 'test'"}, style="params"))
+
+ def test_long_strings_are_truncated_after_escaping(self):
+ self.assertEqual("{s: '" + "x" * 50 + "'}", format_dict({"s": "x" * 50}))
+ self.assertEqual("{s: '" + "x" * 50 + "...'}", format_dict({"s": "x" * 51}))
+ # Escaping runs first, so fifty backslashes are a hundred characters and are cut.
+ self.assertEqual("{s: '" + "\\" * 50 + "...'}", format_dict({"s": "\\" * 50}))
+ # Quote escaping runs last, so a truncated string of quotes loses its tail to the cap.
+ self.assertEqual("{s: '" + "\\'" * 50 + "...'}", format_dict({"s": "'" * 60}))
+
+ def test_a_single_element_tuple_keeps_its_trailing_comma(self):
+ self.assertEqual("{t: (1,), l: [1], s: {1}}", format_dict({"t": (1,), "l": [1], "s": {1}}))
+ self.assertEqual("{t: 1}", format_dict({"t": 1}))
+ self.assertEqual("{t: (1, 2)}", format_dict({"t": (1, 2)}))
+
+ def test_an_unregistered_type_falls_back_to_its_repr(self):
+ class Unregistered:
+ def __repr__(self) -> str:
+ return "<unregistered>"
+
+ def __str__(self) -> str:
+ return "<str form that repr must win over>"
+
+ # repr, not str: the two disagree here so a str fallback would render the other form.
+ self.assertEqual("{a: <unregistered>}", format_dict({"a": Unregistered()}))
+ self.assertEqual(
+ "{a: frozenset({1}), b: bytearray(b'x')}",
+ format_dict({"a": frozenset({1}), "b": bytearray(b"x")}),
+ )
+
+ def test_an_array_renders_as_its_shape(self):
+ self.assertEqual(
+ "{a: array(3, 4), b: array(2,)}",
+ format_dict({"a": np.zeros((3, 4)), "b": np.array([1.0, 2.0])}),
+ )
+
+ def test_repeated_renders_of_the_same_structure_are_byte_identical(self):
+ payload = {
+ "s": {"delta", "alpha", "bravo", "echo", "foxtrot"},
+ "i": {10, 2, 33, 4, 55},
+ "l": [1.25, None, True, (2,), {"a"}],
+ "n": {"x": {"y": 2.5}},
+ "a": np.zeros((2, 2)),
+ }
+ for style in ("dict", "params"):
+ with self.subTest(style=style):
+ expected = format_dict(payload, style=style)
+ self.assertTrue(
+ all(format_dict(payload, style=style) == expected for _ in range(100))
+ )
+ self.assertEqual("{n: {x: {y: 2.5}}}", format_dict({"n": payload["n"]}))
+
+ def test_the_output_does_not_depend_on_the_interpreter_hash_seed(self):
+ # Sets iterate in hash order, so this is the property that makes the log line stable
+ # across restarts. A subprocess is the only way to vary PYTHONHASHSEED, which is read
+ # once at interpreter start.
+ payload = {
+ "s": {
+ "delta",
+ "alpha",
+ "charlie",
+ "bravo",
+ "echo",
+ "foxtrot",
+ "golf",
+ "hotel",
+ "india",
+ },
+ "i": {10, 2, 33, 4, 55, 6, 77, 8, 99},
+ "t": {(1, 2), frozenset({3}), "z"},
+ "l": ["b", "a", "c"],
+ }
+ script = f"from Utils import format_dict\nprint(format_dict({payload!r}, style='params'))\n"
+ rendered = set()
+ for seed in ("0", "1", "12345", "999"):
+ with self.subTest(seed=seed):
+ proc = subprocess.run(
+ [sys.executable, "-c", script],
+ capture_output=True,
+ text=True,
+ check=True,
+ env=dict(os.environ, PYTHONHASHSEED=seed),
+ )
+ rendered.add(proc.stdout.strip())
+ self.assertEqual(1, len(rendered))
+
+
+if __name__ == "__main__":
+ unittest.main()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()
--- /dev/null
+"""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()