]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
chore: merge upstream/main preserving documentation semantics
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 20:34:57 +0000 (22:34 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 2 Oct 2026 20:34:57 +0000 (22:34 +0200)
1  2 
README.md
ReforceXY/reward_space_analysis/README.md
ReforceXY/reward_space_analysis/tests/README.md
ReforceXY/tests/test_strategy_semantics.py

diff --cc README.md
index e8f16cfc59ae8b05a626ad9614050d482665546e,4cb56247c5f83d9ac03130239e7775fc9048d4cf..b47e1bfcad5e3d87f8b74b4d9e7cf3665bf40878
+++ b/README.md
@@@ -395,13 -395,11 +395,13 @@@ Freqtrade's `proposed_leverage` before 
  Continual learning trains an independent copy of the deployed policy with its
  fitted feature pipeline. DQN/QRDQN deployments each persist their replay buffer;
  it is loaded only when continual training starts. Missing or incompatible replay
 -data prevents continuation but does not prevent inference. Reset trained models
 -or use a new `freqai.identifier` to migrate incompatible artifacts, including
 -deployments without the chronological training marker. Training disables
 -`shuffle_after_split`. HPO studies and saved best parameters are reused only
 -when their objective identity matches.
 +data prevents continuation but does not prevent inference. The deployment
- generation covers frozen feature coordinates and PBRS reward semantics. Older
- generations, including `chronological-frozen-pipelines-v2`, remain usable for
++generation covers frozen feature coordinates and PBRS reward semantics. Otherwise
++compatible older generations, including `chronological-frozen-pipelines-v2`, support
 +inference but cannot continue training. Reset trained models or use a new
 +`freqai.identifier` to migrate. Training disables `shuffle_after_split`. HPO
 +studies and saved best parameters are reused only when their objective identity
 +matches.
  
  Backtests continue only from a saved policy whose training cutoff precedes
  the current window's end and its last available candle boundary. A later
@@@ -426,9 -424,6 +426,9 @@@ Rows with an invalid `date_pred` are di
  discarded-row count; valid duplicates retain the same precedence.
  See the model docstrings for continuation, HPO and statistics details.
  
- An expired model closes only open positions for the current pair, using each
- position's own side; positions for other pairs cannot generate its exit signals.
++An expired model emits exit signals only for open positions of the current pair,
++using each position's side; other pairs cannot generate its exit signals.
 +
  With `hold_potential_enabled=true`, ReforceXY enables `add_state_info` before
  constructing environments so training and inference use the same observations.
  Freqtrade does not support these state observations in backtesting; disable hold
@@@ -481,80 -475,91 +481,85 @@@ candle whose price filled the event. Ac
  terminal liquidations use the returned post-increment tick. `terminal_liquidation`
  and `exit_pnl` remain on the transition history row.
  
 +Every enabled PBRS transition, including neutral steps with retained potential,
 +uses `gamma * next_potential - prev_potential`. Both `canonical` and
 +`non_canonical` have zero exit potential; the latter permits optional additives.
 +The `pbrs_invariant` diagnostic recognizes either zero-exit mode when both
 +effective additives are disabled. It is a conservative configuration check, not
 +a guarantee about fitted policies: theoretical invariance also requires an
 +observable state potential, the learner's discount, and appropriate boundaries.
 +
 +An absent configured `gamma` uses the canonical discount. The effective discount
 +must be a finite Python/NumPy integer or floating scalar in `[0, 1]`, normalized
 +to a Python float. Null, booleans, strings, complex values and arrays are rejected
 +before constructing or replacing environments. Validation follows HPO overrides
 +and the resumed learner's gamma, so a valid higher-priority value can supersede
 +an invalid base value. A persisted learner with missing or invalid gamma cannot
 +continue training. Learner and environment use the same effective discount.
 +The corrected reward objective invalidates persisted HPO studies and best
 +parameters from the previous objective identity; incompatible studies are reset
 +and stale best-parameter payloads are ignored rather than warm-started.
 +
  ## Development
  
 -### Runtime regressions
 +### Runtime and coverage
  
 -Run each suite in its matching Freqtrade QA image, with the repository mounted
 -at `/workspace` and `/workspace` as the working directory:
 +Run a strategy's suite inside its matching Freqtrade QA image, with the checkout
 +mounted at `/workspace` and that directory as the working directory. Use the
 +shared runner for canonical discovery and the coverage gate:
  
  ```shell
 -# ReforceXY
 -python -m unittest discover -s ReforceXY/tests -v
 -
 -# QuickAdapter
 -PYTHONPATH=/workspace/quickadapter/user_data/strategies \
 -  python -m unittest discover -s quickadapter/tests -v
 +# Inside the ReforceXY QA image; select quickadapter inside its own QA image.
 +strategy=ReforceXY
 +export PYTHONPATH=/workspace/$strategy/user_data/strategies
 +export COVERAGE_RCFILE=$strategy/.coveragerc COVERAGE_FILE=/tmp/.coverage
 +sh scripts/run-coverage.sh -s "$strategy/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. QuickAdapter's regressions additionally run under
 -`coverage.py`; the reward-space analysis suite runs separately with `uv`,
 -without a Freqtrade image.
 -
 -### Coverage gate
 -
 -Within the strategy QA matrix, QuickAdapter is the only strategy whose runtime
 -regressions enforce a coverage gate. The standalone reward-space analysis suite
 -has its own gate; see [its testing documentation](ReforceXY/reward_space_analysis/tests/README.md).
 -QuickAdapter's 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
 -
 -sh scripts/run-coverage.sh -s quickadapter/tests -v
 -```
 -
 -The shared runner gives both coverage commands the same environment. When running
 -`coverage run` and `coverage report` separately, export both variables for both
 -commands: prefixing only the run command does not configure the later report,
 -which then looks in its default data path and fails with `No data to report.`
 -
 -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 or undated measurement annotation, a measurement below
 -the floor it justifies, a floor below the current minimum, a floor that is not a
 -percentage, a `precision` coarse enough to round the total past the floor, a
 -disabled branch trace, a disabled namespace walk, a source tree that is not the
 -measured one, a `fail_under` that has drifted into the inert `[run]` section, the
 -source-level `no cover` or `no branch` pragmas (with or without a colon),
 -ellipsis-only bodies, additional `TYPE_CHECKING` blocks beyond the existing
 -import-only block, and any `omit`, `include`, `exclude_lines`, `exclude_also` or
 -`partial_*` of production code. The shared `scripts/run-coverage.sh` runner is
 -exercised with real passing and failing coverage reports and a failing test suite;
 -the checks assert process exit status, not workflow token spelling.
+ QuickAdapter regressions cover normalized FRAMA with an independent numerical
+ oracle and `custom_exit` with native Freqtrade trades and filled orders. Callback
+ cases include model expiration, outliers, reversal confirmation and persisted
+ final take-profit trails for long and short positions, including restart behavior.
+ 
 +`COVERAGE_RCFILE` explicitly selects the strategy configuration; coverage.py does
 +not search parent directories. Both coverage commands need the same configuration
 +and data path. The runner propagates test and coverage-report failures.
 +QuickAdapter also needs the strategy path for bare-name imports; it is optional
 +for ReforceXY.
 +
 +For a focused debug run without coverage, use `python -m unittest discover` with
 +the same suite path and an exact `module.Class.method` pattern, for example
 +`-k 'test_model_pbrs_transitions.*' -v` for ReforceXY. A pattern matching no tests
 +fails with exit code 5. This debug run does not establish the coverage gate.
 +
 +Each strategy QA matrix entry runs its type check and canonical coverage suite.
 +ReforceXY additionally runs once with `FREQAI_QA_SHUFFLE_SEED=1`. This permutes
 +methods within test classes, not module or class order. `QaTestCase` restores the
 +action-mask cache and the Python, NumPy and CPU Torch RNG states before and after
 +each test. Lifecycle probes cover successful cases, body failures and setup or
 +teardown errors. Tests must isolate other mutable state themselves; the alternate
 +order does not prove the absence of all global-state leaks.
 +
 +The ReforceXY gate measures **only `ReforceXY/user_data`**, with branch coverage
 +and non-imported namespace files included. Tests, the analytical package and
 +QuickAdapter do not contribute to that percentage. Its minimum is **70%**, with
 +an approximately 75% coverage objective. Each strategy's current measurement and
 +configured floor are authoritative in its `.coveragerc`. The reward-space analysis
 +suite runs separately with `uv` and has
 +[its own gate](ReforceXY/reward_space_analysis/tests/README.md).
 +
 +CPU QA does not execute CUDA device paths. Compatibility guards, including the
 +rejection of asynchronous drawer methods, are testable; a coverage gap is not
 +evidence that code is unreachable. Gaps remain in the measured denominator.
 +
 +`fail_under` is an absolute aggregate floor, not a per-module floor. Re-measure
 +using the shipped branch and namespace configuration before raising it; update
 +the dated measurement annotation and floor together. Do not lower the ReforceXY
 +minimum or narrow its measurement scope. Its guards reject alternate roots,
 +plugins, exclusions, source pragmas, placeholder bodies, invalid options and
 +default exclusions that would remove executable statements or branches. The
 +shared runner is exercised against passing and failing coverage reports and
 +failing suites by process exit status.
  
  ### Quality checks
  
index ee9499d3cd24a78365a358c55556e4bcec04b342,5000b4e919cff96a5f65110ac318af0f10dfb16a..0f6155bc80c992e34a5882724660c948df6eb870
@@@ -357,12 -328,12 +357,15 @@@ where `kernel_function` depends on `exi
  | Parameter                    | Default | Description                                                                               |
  | ---------------------------- | ------- | ----------------------------------------------------------------------------------------- |
  | `max_trade_duration_candles` | 128     | Trade duration cap                                                                        |
- | `max_idle_duration_candles`  | None    | Missing/None derives 4× trade cap; explicit zero uses denominator 1 and does not disable the penalty |
 -| `max_idle_duration_candles`  | None    | Idle hazard threshold (4× trade duration fallback); the idle clock keeps counting past it |
++| `max_idle_duration_candles`  | None    | Idle hazard threshold; missing/None derives 4× trade cap; idle clock keeps counting past it |
  | `idle_penalty_ratio`         | 1.0     | Idle penalty ratio                                                                        |
  | `idle_penalty_power`         | 1.025   | Idle penalty exponent                                                                     |
  | `hold_penalty_ratio`         | 1.0     | Hold penalty ratio                                                                        |
  | `hold_penalty_power`         | 1.025   | Hold penalty exponent                                                                     |
  
++An explicit zero threshold uses denominator 1 and does not disable the idle
++penalty.
++
  #### Validation
  
  | Parameter               | Default | Description                       |
@@@ -648,15 -619,11 +651,16 @@@ configuration
  
  ### PBRS Configuration
  
 -Canonical mode enforces terminal release (Φ terminal ≈ 0) and suppresses
 -entry/exit additive terms.
 -
 -Non-canonical exit modes can introduce non-zero terminal shaping; enable
 -additives only when you want those extra terms to contribute.
 +Every enabled PBRS transition uses `gamma * next_potential - prev_potential`,
 +including neutral self-loops that retain potential. Termination clears the
 +potential in every exit mode. `canonical` suppresses entry/exit additives;
 +`non_canonical` also has zero exit potential but permits those optional terms.
 +Both zero-exit modes without effective additives are eligible for observed PBRS
 +verification. The report's canonical classification describes the shaping form,
 +not just the literal exit-mode name, and does not guarantee fitted-policy
- equivalence. Residual exit modes retain history-dependent potential and are not
- classified as generally policy-invariant.
++equivalence. The remaining exit modes (`progressive_release`, `spike_cancel`,
++`retain_previous`) can retain history-dependent potential after a voluntary exit;
++they are not classified as generally policy-invariant.
  
  ### Real Data Comparison
  
index 15e8859c3e09b2c53f328c8dd1e41241f2f07c7e,4b0bc00e42739643f51c78d752cc85619572c65f..875caba5eda873374df2b7dad5853e1517e7a888
@@@ -328,16 -327,6 +328,18 @@@ Detailed assertions reside in targeted 
  while integration tests focus on report formatting. The mapping above defines
  current ownership; multi-path and non-owning references are called out explicitly.
  
- Run it in the Freqtrade RL QA image through the canonical runtime suite; the
 +Real runtime/reference parity is owned by
 +`ReforceXY/tests/test_model_pbrs_transitions.py::PbrsTransitionsTest::test_analysis_matches_real_rewards_across_modes_and_parameter_boundaries`.
++Native-precision rejection of out-of-range analytical discounts is owned by
++`ReforceXY/tests/test_model_pbrs_transitions.py::PbrsTransitionsTest::test_analysis_rejects_gamma_outside_bounds_before_float_rounding`.
++Run these tests in the Freqtrade RL QA image through the canonical runtime suite; the
 +standalone analysis environment deliberately does not require Freqtrade/SB3.
 +The comparator exercises direct/strict/relaxed parameter paths, real fills and
 +terminal liquidation, carries reference potential independently, and checks tiny
 +shaping components more tightly than the report-verification tolerance.
 +Raw random shaping sums are not discounted invariance oracles. Complete-episode
 +discounted identities and report continuity checks retain their existing owners.
 +
  ## When to Run Tests
  
  Run after changes to: reward component logic, PBRS mechanics, CLI
index 23ba4e66a33b1e688e049491d70943acab949e32,0000000000000000000000000000000000000000..2c38f113bee6042e45f672e7116501c63448734b
mode 100644,000000..100644
--- /dev/null
@@@ -1,346 -1,0 +1,346 @@@
-     def test_an_expired_model_closes_only_open_positions_for_the_current_pair(self):
 +"""Strategy signal, validation and sizing contracts; requires the Freqtrade QA image."""
 +
 +import unittest
 +from datetime import datetime, timezone
 +from unittest import mock
 +
 +import numpy as np
 +import pandas as pd
 +from freqtrade.persistence import LocalTrade, Trade
 +from qa_support import QaTestCase
 +
 +from ReforceXY.user_data.freqaimodels.ReforceXY import Actions
 +from ReforceXY.user_data.strategies.RLAgentStrategy import (
 +    ACTION_COLUMN,
 +    RLAgentStrategy,
 +    _ensure_datetime_series,
 +)
 +
 +
 +def _raised(column):
 +    """True where the flag is set. An untouched row is NaN, not 0: the column is created
 +    by the assignment, so "no signal" arrives as a missing float rather than a zero."""
 +    return [bool(value) if value == value else False for value in column]
 +
 +
 +def _frame(actions, do_predict=1, count=None):
 +    values = list(actions)
 +    count = count if count is not None else len(values)
 +    return pd.DataFrame(
 +        {
 +            "open": np.linspace(100.0, 110.0, count),
 +            "high": np.linspace(101.0, 111.0, count),
 +            "low": np.linspace(99.0, 109.0, count),
 +            "close": np.linspace(100.5, 110.5, count),
 +            "volume": np.full(count, 10.0),
 +            "date": pd.date_range("2024-01-01", periods=count, freq="5min", tz="UTC"),
 +            ACTION_COLUMN: values,
 +            "do_predict": [do_predict] * count,
 +        }
 +    )
 +
 +
 +class DateColumnTest(QaTestCase):
 +    def test_a_missing_date_column_is_refused(self):
 +        with self.assertRaises(ValueError):
 +            _ensure_datetime_series(None)
 +
 +    def test_datetime_conversion_preserves_instants_and_index(self):
 +        series = pd.Series(
 +            pd.date_range("2024-01-01", periods=3, freq="5min", tz="UTC"),
 +            index=[7, 11, 19],
 +        )
 +        expected = pd.Series(
 +            [
 +                pd.Timestamp("2024-01-01T00:00:00Z"),
 +                pd.Timestamp("2024-01-01T00:05:00Z"),
 +                pd.Timestamp("2024-01-01T00:10:00Z"),
 +            ],
 +            index=[7, 11, 19],
 +            dtype="datetime64[ms, UTC]",
 +        )
 +        pd.testing.assert_series_equal(expected, _ensure_datetime_series(series))
 +
 +    def test_an_integer_column_inside_the_epoch_range_is_read_as_milliseconds(self):
 +        millis = int(pd.Timestamp("2024-01-01", tz="UTC").value // 1_000_000)
 +        series = _ensure_datetime_series(pd.Series([millis, millis + 300_000]))
 +        pd.testing.assert_series_equal(
 +            pd.Series(
 +                [
 +                    pd.Timestamp("2024-01-01T00:00:00Z"),
 +                    pd.Timestamp("2024-01-01T00:05:00Z"),
 +                ],
 +                dtype="datetime64[ms, UTC]",
 +            ),
 +            series,
 +        )
 +
 +    def test_an_integer_epoch_in_seconds_is_refused_as_a_corrupted_unit(self):
 +        millis = int(pd.Timestamp("2024-01-01", tz="UTC").value // 1_000_000)
 +        with self.assertRaises(ValueError):
 +            _ensure_datetime_series(pd.Series([millis // 1000]))
 +
 +    def test_an_all_null_integer_column_is_read_without_a_range_probe(self):
 +        # Nothing to probe means nothing to reject; refusing here would reject a column
 +        # that has no data to be wrong about.
 +        series = _ensure_datetime_series(pd.Series([None, None], dtype="Int64"))
 +        self.assertTrue(series.isna().all())
 +
 +
 +class TradingModeTest(QaTestCase):
 +    def _strategy(self, **config):
 +        strategy = RLAgentStrategy.__new__(RLAgentStrategy)
 +        strategy.config = config
 +        return strategy
 +
 +    def test_the_two_leveraged_modes_allow_shorting(self):
 +        for mode in ("margin", "futures"):
 +            with self.subTest(mode=mode):
 +                self.assertTrue(self._strategy(trading_mode=mode).is_short_allowed())
 +
 +    def test_spot_forbids_shorting(self):
 +        self.assertFalse(self._strategy(trading_mode="spot").is_short_allowed())
 +
 +    def test_an_undeclared_trading_mode_is_refused_rather_than_defaulted(self):
 +        with self.assertRaises(ValueError):
 +            self._strategy(trading_mode="margin-ish").is_short_allowed()
 +
 +    def test_can_short_follows_the_mode(self):
 +        self.assertTrue(self._strategy(trading_mode="futures").can_short)
 +        self.assertFalse(self._strategy(trading_mode="spot").can_short)
 +
 +
 +class ProtectionConfigTest(QaTestCase):
 +    def _strategy(self, custom_protections):
 +        strategy = RLAgentStrategy.__new__(RLAgentStrategy)
 +        strategy.config = {"custom_protections": custom_protections}
 +        return strategy
 +
 +    def test_an_empty_list_is_accepted(self):
 +        self.assertEqual([], self._strategy([]).protections)
 +
 +    def test_a_protection_without_a_method_name_is_refused(self):
 +        strategy = self._strategy([{"stop_duration": 2}])
 +        with self.assertRaises(ValueError):
 +            _ = strategy.protections
 +
 +    def test_a_non_list_protection_value_is_refused(self):
 +        strategy = self._strategy({"method": "CooldownPeriod"})
 +        with self.assertRaises(ValueError):
 +            _ = strategy.protections
 +
 +
 +class LeverageTest(QaTestCase):
 +    def _strategy(self, configured):
 +        strategy = RLAgentStrategy.__new__(RLAgentStrategy)
 +        strategy.config = {"leverage": configured}
 +        strategy._leverage_warning_state = None
 +        return strategy
 +
 +    def _arguments(self, **overrides):
 +        arguments = {
 +            "pair": "BTC/USDT",
 +            "current_time": datetime(2026, 1, 1, tzinfo=timezone.utc),
 +            "current_rate": 100.0,
 +            "proposed_leverage": 2.0,
 +            "max_leverage": 5.0,
 +            "entry_tag": None,
 +            "side": "long",
 +        }
 +        arguments.update(overrides)
 +        return arguments
 +
 +    def test_absent_and_null_configuration_use_the_proposed_leverage(self):
 +        strategy = self._strategy(None)
 +        self.assertEqual(2.0, strategy.leverage(**self._arguments()))
 +        strategy.config.clear()
 +        self.assertEqual(2.0, strategy.leverage(**self._arguments()))
 +
 +    def test_a_configured_value_is_clamped_into_the_pair_bounds(self):
 +        for configured, expected in ((1.5, 1.5), (0.5, 1.0), (-5.0, 1.0), (9.0, 5.0)):
 +            with self.subTest(configured=configured):
 +                self.assertEqual(expected, self._strategy(configured).leverage(**self._arguments()))
 +
 +    def test_a_non_numeric_configuration_falls_back_to_the_proposed_leverage(self):
 +        for configured in ("two", [1], {"a": 1}):
 +            with self.subTest(configured=configured):
 +                self.assertEqual(2.0, self._strategy(configured).leverage(**self._arguments()))
 +
 +    def test_a_boolean_is_not_read_as_a_number(self):
 +        # bool is an int subclass, so float(True) succeeds; treating 1.0 as a leverage
 +        # configuration would silently disable the clamp.
 +        self.assertEqual(2.0, self._strategy(True).leverage(**self._arguments()))
 +
 +    def test_a_non_finite_configuration_falls_back_to_the_proposed_leverage(self):
 +        for configured in (float("nan"), float("inf"), float("-inf")):
 +            with self.subTest(configured=configured):
 +                self.assertEqual(2.0, self._strategy(configured).leverage(**self._arguments()))
 +
 +    def test_the_result_is_always_at_least_one(self):
 +        self.assertEqual(1.0, self._strategy(0.5).leverage(**self._arguments(max_leverage=0.2)))
 +
 +    def test_integer_conversion_overflow_falls_back_instead_of_escaping_the_callback(self):
 +        self.assertEqual(2.0, self._strategy(10**500).leverage(**self._arguments()))
 +
 +    def test_warnings_follow_invalid_configuration_transitions(self):
 +        strategy = self._strategy(float("nan"))
 +        with self.assertLogs(RLAgentStrategy.__module__, level="WARNING") as logs:
 +            for _ in range(3):
 +                self.assertEqual(strategy.leverage(**self._arguments()), 2.0)
 +            self.assertEqual(len(logs.records), 1)
 +            strategy.config["leverage"] = float("inf")
 +            self.assertEqual(strategy.leverage(**self._arguments()), 2.0)
 +            self.assertEqual(len(logs.records), 2)
 +            strategy.config["leverage"] = 0.5
 +            for _ in range(2):
 +                self.assertEqual(strategy.leverage(**self._arguments()), 1.0)
 +            self.assertEqual(len(logs.records), 3)
 +            strategy.config["leverage"] = 2.5
 +            self.assertEqual(strategy.leverage(**self._arguments()), 2.5)
 +            strategy.config["leverage"] = float("nan")
 +            self.assertEqual(strategy.leverage(**self._arguments()), 2.0)
 +            self.assertEqual(len(logs.records), 4)
 +
 +    def test_missing_configuration_resets_warning_deduplication(self):
 +        strategy = self._strategy("invalid")
 +        with self.assertLogs(RLAgentStrategy.__module__, level="WARNING") as logs:
 +            self.assertEqual(strategy.leverage(**self._arguments()), 2.0)
 +            strategy.config.clear()
 +            self.assertEqual(strategy.leverage(**self._arguments()), 2.0)
 +            self.assertEqual(len(logs.records), 1)
 +            strategy.config["leverage"] = "invalid"
 +            self.assertEqual(strategy.leverage(**self._arguments()), 2.0)
 +            self.assertEqual(len(logs.records), 2)
 +
 +
 +class EntrySignalTest(QaTestCase):
 +    def _strategy(self):
 +        return RLAgentStrategy.__new__(RLAgentStrategy)
 +
 +    def test_a_long_entry_action_raises_the_long_flag_and_nothing_else(self):
 +        frame = _frame([1, 0, 0])
 +        result = self._strategy().populate_entry_trend(frame, {"pair": "BTC/USDT"})
 +        self.assertEqual([True, False, False], _raised(result["enter_long"]))
 +        self.assertEqual([False] * 3, _raised(result["enter_short"]))
 +
 +    def test_a_short_entry_action_raises_the_short_flag(self):
 +        result = self._strategy().populate_entry_trend(_frame([3, 0]), {"pair": "BTC/USDT"})
 +        self.assertEqual([True, False], _raised(result["enter_short"]))
 +
 +    def test_no_action_raises_no_entry(self):
 +        for action in (0, 2, 4):
 +            with self.subTest(action=action):
 +                result = self._strategy().populate_entry_trend(
 +                    _frame([action]), {"pair": "BTC/USDT"}
 +                )
 +                self.assertFalse(_raised(result["enter_long"])[0])
 +                self.assertFalse(_raised(result["enter_short"])[0])
 +
 +    def test_a_row_without_a_prediction_raises_no_entry(self):
 +        # do_predict != 1 means the prediction is not yet confirmed; acting on it would
 +        # trade on a value the model has not published. Both sides are exercised, because
 +        # the two conditions are independent and either one can be dropped on its own.
 +        result = self._strategy().populate_entry_trend(
 +            _frame(
 +                [Actions.Long_enter.value, Actions.Short_enter.value],
 +                do_predict=0,
 +            ),
 +            {"pair": "BTC/USDT"},
 +        )
 +        self.assertEqual([False, False], _raised(result["enter_long"]))
 +        self.assertEqual([False, False], _raised(result["enter_short"]))
 +
 +
 +class ExitSignalTest(QaTestCase):
 +    def _strategy(self):
 +        return RLAgentStrategy.__new__(RLAgentStrategy)
 +
 +    def test_an_exit_action_raises_only_its_own_side(self):
 +        result = self._strategy().populate_exit_trend(_frame([2, 0, 4]), {"pair": "BTC/USDT"})
 +        self.assertEqual([True, False, False], _raised(result["exit_long"]))
 +        self.assertEqual([False, False, True], _raised(result["exit_short"]))
 +
 +    def test_an_entry_action_raises_no_exit(self):
 +        for action in (1, 3):
 +            with self.subTest(action=action):
 +                result = self._strategy().populate_exit_trend(
 +                    _frame([action]), {"pair": "BTC/USDT"}
 +                )
 +                self.assertFalse(_raised(result["exit_long"])[0])
 +                self.assertFalse(_raised(result["exit_short"])[0])
 +
++    def test_an_expired_model_signals_exits_only_for_open_positions_of_the_current_pair(self):
 +        closed = LocalTrade(pair="BTC/USDT", is_open=False, is_short=False)
 +        long = LocalTrade(pair="BTC/USDT", is_open=True, is_short=False)
 +        short = LocalTrade(pair="BTC/USDT", is_open=True, is_short=True)
 +        unrelated_long = LocalTrade(pair="ETH/USDT", is_open=True, is_short=False)
 +        unrelated_short = LocalTrade(pair="ETH/USDT", is_open=True, is_short=True)
 +        for open_trades, expected in (
 +            ([], (False, False)),
 +            ([short], (False, True)),
 +            ([long, unrelated_short], (True, False)),
 +            ([short, unrelated_long], (False, True)),
 +            ([unrelated_long], (False, False)),
 +            ([unrelated_short], (False, False)),
 +        ):
 +            with self.subTest(expected=expected, trades=open_trades):
 +                with (
 +                    mock.patch.object(Trade, "use_db", False),
 +                    mock.patch.object(LocalTrade, "bt_trades", [closed]),
 +                    mock.patch.object(LocalTrade, "bt_trades_open", open_trades),
 +                ):
 +                    result = self._strategy().populate_exit_trend(
 +                        _frame([Actions.Neutral.value], do_predict=2), {"pair": "BTC/USDT"}
 +                    )
 +                self.assertEqual([expected[0]], _raised(result["exit_long"]))
 +                self.assertEqual([expected[1]], _raised(result["exit_short"]))
 +
 +    def test_a_row_without_a_prediction_raises_no_exit(self):
 +        result = self._strategy().populate_exit_trend(
 +            _frame(
 +                [Actions.Long_exit.value, Actions.Short_exit.value],
 +                do_predict=0,
 +            ),
 +            {"pair": "BTC/USDT"},
 +        )
 +        self.assertEqual([False, False], _raised(result["exit_long"]))
 +        self.assertEqual([False, False], _raised(result["exit_short"]))
 +
 +
 +class FeatureEngineeringTest(QaTestCase):
 +    def _strategy(self):
 +        return RLAgentStrategy.__new__(RLAgentStrategy)
 +
 +    def test_time_features_encode_week_and_day_boundaries_with_the_original_index(self):
 +        frame = _frame([0, 0, 0])
 +        frame.index = [7, 13, 29]
 +        frame["date"] = pd.to_datetime(
 +            ["2024-01-01T00:00:00Z", "2024-01-07T23:00:00Z", "2024-01-08T00:00:00Z"]
 +        )
 +        result = self._strategy().feature_engineering_standard(frame, {})
 +        self.assertEqual([7, 13, 29], list(result.index))
 +        np.testing.assert_allclose(result["%-day_of_week"], [1 / 7, 1, 1 / 7], atol=1e-12)
 +        np.testing.assert_allclose(result["%-hour_of_day"], [1 / 25, 24 / 25, 1 / 25], atol=1e-12)
 +
 +    def test_log_returns_keep_positive_negative_and_flat_moves_in_index_order(self):
 +        frame = _frame([0, 0, 0, 0])
 +        frame.index = [7, 13, 29, 41]
 +        frame["close"] = [100.0, 110.0, 99.0, 99.0]
 +        result = self._strategy().feature_engineering_expand_basic(frame, {})
 +        self.assertEqual([7, 13, 29, 41], list(result.index))
 +        np.testing.assert_allclose(
 +            result["%-close_log_return"],
 +            [np.nan, np.log(1.1), np.log(0.9), 0.0],
 +            atol=1e-12,
 +            equal_nan=True,
 +        )
 +
 +    def test_the_target_column_is_the_neutral_action(self):
 +        result = self._strategy().set_freqai_targets(
 +            _frame([Actions.Long_enter.value, Actions.Short_enter.value]), {}
 +        )
 +        self.assertEqual([0, 0], list(result[ACTION_COLUMN]))
 +
 +
 +if __name__ == "__main__":
 +    unittest.main()