From: Jérôme Benoit Date: Fri, 2 Oct 2026 20:34:57 +0000 (+0200) Subject: chore: merge upstream/main preserving documentation semantics X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=f936b1585d70f2457c8eb2aec36da32403997be1;p=freqai-strategies.git chore: merge upstream/main preserving documentation semantics --- f936b1585d70f2457c8eb2aec36da32403997be1 diff --cc README.md index e8f16cf,4cb5624..b47e1bf --- a/README.md +++ 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 ``` + 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. + -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. +`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 diff --cc ReforceXY/reward_space_analysis/README.md index ee9499d,5000b4e..0f6155b --- a/ReforceXY/reward_space_analysis/README.md +++ b/ReforceXY/reward_space_analysis/README.md @@@ -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 diff --cc ReforceXY/reward_space_analysis/tests/README.md index 15e8859,4b0bc00..875caba --- a/ReforceXY/reward_space_analysis/tests/README.md +++ b/ReforceXY/reward_space_analysis/tests/README.md @@@ -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. +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`. - Run it in the Freqtrade RL QA image through the canonical runtime suite; the ++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 diff --cc ReforceXY/tests/test_strategy_semantics.py index 23ba4e6,0000000..2c38f11 mode 100644,000000..100644 --- a/ReforceXY/tests/test_strategy_semantics.py +++ b/ReforceXY/tests/test_strategy_semantics.py @@@ -1,346 -1,0 +1,346 @@@ +"""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_closes_only_open_positions_for_the_current_pair(self): ++ 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()