]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
refactor(reforcexy): cache supported modes as immutable constants
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 2 Jul 2026 23:50:09 +0000 (01:50 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 2 Jul 2026 23:50:09 +0000 (01:50 +0200)
ReforceXY/user_data/freqaimodels/ReforceXY.py
ReforceXY/user_data/tests/test_reforcexy_supported_modes.py [new file with mode: 0644]

index 13217533ecfc27611db325aa129a3c5bea97b63a..289869bfdab65e28f8fd8a46852f5e16d987aafe 100644 (file)
@@ -200,6 +200,7 @@ class ReforceXY(BaseReinforcementLearningModel):
         "DQN",
         "QRDQN",
     )
+    _MODEL_TYPES_SET: Final[frozenset[ModelType]] = frozenset(_MODEL_TYPES)
     _SCHEDULE_TYPES_KNOWN: Final[Tuple[ScheduleTypeKnown, ...]] = ("linear", "constant")
     _SCHEDULE_TYPES: Final[Tuple[ScheduleType, ...]] = (
         *_SCHEDULE_TYPES_KNOWN,
@@ -212,6 +213,9 @@ class ReforceXY(BaseReinforcementLearningModel):
         "spike_cancel",
         "retain_previous",
     )
+    _EXIT_POTENTIAL_MODES_SET: Final[frozenset[ExitPotentialMode]] = frozenset(
+        _EXIT_POTENTIAL_MODES
+    )
     _TRANSFORM_FUNCTIONS: Final[Tuple[TransformFunction, ...]] = (
         "tanh",
         "softsign",
@@ -220,6 +224,9 @@ class ReforceXY(BaseReinforcementLearningModel):
         "asinh",
         "clip",
     )
+    _TRANSFORM_FUNCTIONS_SET: Final[frozenset[TransformFunction]] = frozenset(
+        _TRANSFORM_FUNCTIONS
+    )
     _EXIT_ATTENUATION_MODES: Final[Tuple[ExitAttenuationMode, ...]] = (
         "legacy",
         "sqrt",
@@ -256,18 +263,6 @@ class ReforceXY(BaseReinforcementLearningModel):
 
     _action_masks_cache: ClassVar[Dict[Tuple[bool, float], NDArray[np.bool_]]] = {}
 
-    @staticmethod
-    def _model_types_set() -> set[ModelType]:
-        return set(ReforceXY._MODEL_TYPES)
-
-    @staticmethod
-    def _exit_potential_modes_set() -> set[ExitPotentialMode]:
-        return set(ReforceXY._EXIT_POTENTIAL_MODES)
-
-    @staticmethod
-    def _transform_functions_set() -> set[TransformFunction]:
-        return set(ReforceXY._TRANSFORM_FUNCTIONS)
-
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
@@ -2011,7 +2006,7 @@ class MyRLEnv(Base5ActionRLEnv):
                 "exit_potential_mode", ReforceXY._EXIT_POTENTIAL_MODES[0]
             )  # "canonical"
         )
-        if self._exit_potential_mode not in set(ReforceXY._EXIT_POTENTIAL_MODES):
+        if self._exit_potential_mode not in ReforceXY._EXIT_POTENTIAL_MODES_SET:
             logger.warning(
                 "PBRS [%s]: exit_potential_mode=%r invalid; defaulting to %r. Valid: %s",
                 self.id,
diff --git a/ReforceXY/user_data/tests/test_reforcexy_supported_modes.py b/ReforceXY/user_data/tests/test_reforcexy_supported_modes.py
new file mode 100644 (file)
index 0000000..cde0b1b
--- /dev/null
@@ -0,0 +1,151 @@
+import importlib
+import inspect
+import io
+import logging
+import math
+import sys
+from collections.abc import Callable
+
+
+sys.path.insert(0, "/freqtrade/user_data")
+reforcexy_module = importlib.import_module("freqaimodels.ReforceXY")
+ReforceXY = reforcexy_module.ReforceXY
+MyRLEnv = reforcexy_module.MyRLEnv
+convert_optuna_params_to_model_params = (
+    reforcexy_module.convert_optuna_params_to_model_params
+)
+
+
+def make_env() -> MyRLEnv:
+    env = object.__new__(MyRLEnv)
+    env.id = "supported-modes-check"
+    env._exit_potential_decay = 0.25
+    return env
+
+
+def capture_reforcexy_warnings(action: Callable[[], None]) -> str:
+    stream = io.StringIO()
+    handler = logging.StreamHandler(stream)
+    logger = logging.getLogger("freqaimodels.ReforceXY")
+    previous_level = logger.level
+    logger.addHandler(handler)
+    logger.setLevel(logging.WARNING)
+    try:
+        action()
+    finally:
+        logger.removeHandler(handler)
+        logger.setLevel(previous_level)
+    return stream.getvalue()
+
+
+def test_supported_mode_sets_are_immutable_constants() -> None:
+    # Given: ReforceXY exposes ordered tuples for user-facing valid-value messages.
+    expected_model_types = ("PPO", "RecurrentPPO", "MaskablePPO", "DQN", "QRDQN")
+    expected_exit_modes = (
+        "canonical",
+        "non_canonical",
+        "progressive_release",
+        "spike_cancel",
+        "retain_previous",
+    )
+    expected_transform_functions = (
+        "tanh",
+        "softsign",
+        "arctan",
+        "sigmoid",
+        "asinh",
+        "clip",
+    )
+
+    # When/Then: adjacent immutable sets mirror the tuples without reordering messages.
+    assert ReforceXY._MODEL_TYPES == expected_model_types
+    assert ReforceXY._MODEL_TYPES_SET == frozenset(expected_model_types)
+    assert ReforceXY._EXIT_POTENTIAL_MODES == expected_exit_modes
+    assert ReforceXY._EXIT_POTENTIAL_MODES_SET == frozenset(expected_exit_modes)
+    assert ReforceXY._TRANSFORM_FUNCTIONS == expected_transform_functions
+    assert ReforceXY._TRANSFORM_FUNCTIONS_SET == frozenset(expected_transform_functions)
+
+
+def test_supported_mode_helpers_do_not_rebuild_sets() -> None:
+    # Given: supported modes are cached on the class.
+    source = inspect.getsource(ReforceXY)
+
+    # When/Then: obsolete repeated builders and direct set construction are absent.
+    assert "def _model_types_set" not in source
+    assert "def _exit_potential_modes_set" not in source
+    assert "def _transform_functions_set" not in source
+    assert "set(ReforceXY._MODEL_TYPES)" not in source
+    assert "set(ReforceXY._EXIT_POTENTIAL_MODES)" not in source
+    assert "set(ReforceXY._TRANSFORM_FUNCTIONS)" not in source
+
+
+def test_model_type_membership_and_invalid_message_are_unchanged() -> None:
+    # Given: model type membership is backed by the same public tuple values.
+    for model_type in ReforceXY._MODEL_TYPES:
+        # When/Then: every public model type remains accepted by membership checks.
+        assert model_type in ReforceXY._MODEL_TYPES_SET
+
+    try:
+        # When: an unsupported model type reaches Optuna parameter conversion.
+        convert_optuna_params_to_model_params("UnsupportedModel", {"learning_rate": 0.1})
+    except ValueError as exc:
+        # Then: the existing public error text remains unchanged.
+        assert str(exc) == "Hyperopt [global]: model type 'UnsupportedModel' not supported"
+    else:
+        raise AssertionError("unsupported model type did not raise ValueError")
+
+
+def test_transform_function_membership_and_invalid_message_are_unchanged() -> None:
+    # Given: a lightweight environment instance and all supported transforms.
+    env = make_env()
+
+    for transform_function in ReforceXY._TRANSFORM_FUNCTIONS:
+        # When/Then: every public transform remains accepted by membership checks and execution.
+        assert transform_function in ReforceXY._TRANSFORM_FUNCTIONS_SET
+        result = env._potential_transform(transform_function, 0.5)
+        assert math.isfinite(result)
+
+    def use_invalid_transform() -> None:
+        env._potential_transform("invalid", 0.5)
+
+    # When: an unsupported transform reaches runtime validation.
+    warnings = capture_reforcexy_warnings(use_invalid_transform)
+
+    # Then: the existing public warning still lists the same valid values in tuple order.
+    assert "potential_transform='invalid' invalid; defaulting to 'tanh'." in warnings
+    assert "Valid: tanh, softsign, arctan, sigmoid, asinh, clip" in warnings
+
+
+def test_exit_potential_mode_membership_and_behavior_are_unchanged() -> None:
+    # Given: a lightweight environment instance and all supported exit potential modes.
+    env = make_env()
+    expected_values = {
+        "canonical": 0.0,
+        "non_canonical": 0.0,
+        "progressive_release": 1.5,
+        "spike_cancel": 4.0,
+        "retain_previous": 2.0,
+    }
+
+    for exit_potential_mode, expected_value in expected_values.items():
+        # When: each mode computes its exit potential.
+        env._exit_potential_mode = exit_potential_mode
+        result = env._compute_exit_potential(prev_potential=2.0, gamma=0.5)
+
+        # Then: every public mode remains accepted and preserves behavior.
+        assert exit_potential_mode in ReforceXY._EXIT_POTENTIAL_MODES_SET
+        assert result == expected_value
+
+    # Then: invalid public warning text still uses the same tuple-ordered valid values.
+    assert ", ".join(ReforceXY._EXIT_POTENTIAL_MODES) == (
+        "canonical, non_canonical, progressive_release, spike_cancel, retain_previous"
+    )
+
+
+if __name__ == "__main__":
+    test_supported_mode_sets_are_immutable_constants()
+    test_supported_mode_helpers_do_not_rebuild_sets()
+    test_model_type_membership_and_invalid_message_are_unchanged()
+    test_transform_function_membership_and_invalid_message_are_unchanged()
+    test_exit_potential_mode_membership_and_behavior_are_unchanged()
+    print("supported mode checks passed")