diff --git a/CHANGELOG.md b/CHANGELOG.md index da508e5..5bdc9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ All notable changes are recorded here. This project follows Semantic Versioning. - Pinned the Freqtrade same-candle contract: position adjustment and its filled order are applied before stop/exit evaluation. The external 2022 Futures report that motivated the hotfix remains unclaimed until its sealed input is supplied. +- Normalized the X7 NaN-tolerant Chaikin helper and its explicit NumPy `float64` + zero buffer into generic Native kernels, keeping old release fixtures on the same + Full Native path instead of falling back during installed-wheel verification. ## 1.5.0 - 2026-08-10 diff --git a/docs/releases/v1.6.0.md b/docs/releases/v1.6.0.md index 4246e18..240b9dc 100644 --- a/docs/releases/v1.6.0.md +++ b/docs/releases/v1.6.0.md @@ -23,6 +23,11 @@ Native checks. The reporter's sealed 2022 Futures input is not present in this repository, so this release does not claim its reported 180-trade result as an exact certificate. Unknown or newly changed active behavior still fails closed. +The source compiler also normalizes the NaN-tolerant Chaikin helper used by the +captured release fixtures, including its explicit NumPy `float64` zero buffer. The +same generic Rust kernel therefore covers both the current helper and older captured +X7 sources without a strategy-version branch. + ## Full Native runtime - Complete Indicator, Signal, Tag, callback, order, wallet, and state execution is diff --git a/python/nfi_backtest_engine/_indicator_ast.py b/python/nfi_backtest_engine/_indicator_ast.py index 08ac469..e04af29 100644 --- a/python/nfi_backtest_engine/_indicator_ast.py +++ b/python/nfi_backtest_engine/_indicator_ast.py @@ -41,6 +41,18 @@ def helper(high, low, close, volume, timeperiod=20): vol_sum = ta.SUM(volume, timeperiod=timeperiod) vol_sum = np.where(vol_sum == 0, np.nan, vol_sum) return mfv_sum / vol_sum +""", + "chaikin-money-flow-rolling-sum": """ +def helper(high, low, close, volume, timeperiod=20): + hl_range = high - low + mfm = np.zeros_like(close, dtype=np.float64) + valid = hl_range != 0 + mfm[valid] = ((close[valid] - low[valid]) - (high[valid] - close[valid])) / hl_range[valid] + mfv = mfm * volume + mfv_sum = __class__.rolling_sum(mfv, timeperiod) + vol_sum = __class__.rolling_sum(volume, timeperiod) + vol_sum = np.where(vol_sum == 0, np.nan, vol_sum) + return mfv_sum / vol_sum """, "safe-percent-change": """ def helper(arr): diff --git a/python/nfi_backtest_engine/indicator_program.py b/python/nfi_backtest_engine/indicator_program.py index 1baeebb..a871be4 100644 --- a/python/nfi_backtest_engine/indicator_program.py +++ b/python/nfi_backtest_engine/indicator_program.py @@ -1442,6 +1442,30 @@ def inline_tuple_helper_call( def array_call(self, node: ast.Call, callable_name: str) -> str: name = callable_name.removeprefix("np.") + if name == "zeros_like": + if len(node.args) != 1: + self.unsupported(node, "numpy zeros_like signature") + if len(node.keywords) > 1: + self.unsupported(node, "numpy zeros_like signature") + explicit_float64 = bool(node.keywords) + if explicit_float64: + keyword = node.keywords[0] + if keyword.arg != "dtype" or _qualified_name(keyword.value) != "np.float64": + self.unsupported(keyword.value, "numpy zeros_like dtype") + inputs = [self.expression(node.args[0])] + template_type = self.node_types[inputs[0]] + if template_type != "f64-column" and not ( + explicit_float64 and template_type == "dynamic" + ): + self.unsupported(node.args[0], "numpy zeros_like template type") + return self.emit( + node, + "array-call", + "f64-column", + inputs=inputs, + parameters={"family": "numpy", "name": name, "arguments": {}}, + lookback=self.merged_lookback(inputs), + ) if name == "full_like": if len(node.args) != 2 or node.keywords: self.unsupported(node, "numpy full_like signature") @@ -2890,7 +2914,11 @@ def _normalized_native_indicator_helper( if matched is None: return None arguments = dict(bound) - if matched in {"chaikin-money-flow", "chaikin-money-flow-legacy"}: + if matched in { + "chaikin-money-flow", + "chaikin-money-flow-legacy", + "chaikin-money-flow-rolling-sum", + }: found, period = compiler.try_static_value(arguments["timeperiod"]) minimum = 2 if matched == "chaikin-money-flow-legacy" else 1 if ( @@ -2900,9 +2928,14 @@ def _normalized_native_indicator_helper( or period < minimum ): compiler.unsupported(arguments["timeperiod"], "chaikin timeperiod") - return matched, [arguments[name] for name in ("high", "low", "close", "volume")], { - "timeperiod": period - } + native_name = ( + "chaikin-money-flow" + if matched == "chaikin-money-flow-rolling-sum" + else matched + ) + return native_name, [ + arguments[name] for name in ("high", "low", "close", "volume") + ], {"timeperiod": period} return matched, [arguments["arr"]], {} diff --git a/tests/test_indicator_program.py b/tests/test_indicator_program.py index 03fffcb..111d54c 100644 --- a/tests/test_indicator_program.py +++ b/tests/test_indicator_program.py @@ -594,6 +594,33 @@ def test_indicator_program_lowers_numpy_buffers_and_static_container_unroll( validate_indicator_program(program) +def test_indicator_program_normalizes_static_float64_zeros_like(tmp_path: Path) -> None: + source = tmp_path / "ZerosLike.py" + source.write_text( + "import numpy as np\n" + "from freqtrade.strategy import IStrategy\n" + "class ZerosLike(IStrategy):\n" + " @staticmethod\n" + " def zero_buffer(values):\n" + " return np.zeros_like(values, dtype=np.float64)\n" + " def populate_indicators(self, dataframe, metadata):\n" + " dataframe['zero'] = self.zero_buffer(dataframe['close'])\n" + " return dataframe\n", + encoding="utf-8", + ) + + program = compile_indicator_program(source, class_name="ZerosLike") + + call = next(node for node in program["nodes"] if node["op"] == "array-call") + assert call["value_type"] == "f64-column" + assert call["parameters"] == { + "family": "numpy", + "name": "zeros_like", + "arguments": {}, + } + validate_indicator_program(program) + + def test_indicator_program_recognizes_legacy_chaikin_volume_sum_contract( tmp_path: Path, ) -> None: @@ -644,6 +671,49 @@ def test_indicator_program_recognizes_legacy_chaikin_volume_sum_contract( validate_indicator_program(program) +def test_indicator_program_normalizes_nan_tolerant_chaikin_rolling_sums( + tmp_path: Path, +) -> None: + source = tmp_path / "RollingChaikin.py" + source.write_text( + "import numpy as np\n" + "from freqtrade.strategy import IStrategy\n" + "class RollingChaikin(IStrategy):\n" + " @staticmethod\n" + " def rolling_sum(arr, timeperiod):\n" + " return arr\n" + " @staticmethod\n" + " def chaikin_money_flow(high, low, close, volume, timeperiod=20):\n" + " hl_range = high - low\n" + " mfm = np.zeros_like(close, dtype=np.float64)\n" + " valid = hl_range != 0\n" + " mfm[valid] = ((close[valid] - low[valid]) - " + "(high[valid] - close[valid])) / hl_range[valid]\n" + " mfv = mfm * volume\n" + " mfv_sum = __class__.rolling_sum(mfv, timeperiod)\n" + " vol_sum = __class__.rolling_sum(volume, timeperiod)\n" + " vol_sum = np.where(vol_sum == 0, np.nan, vol_sum)\n" + " return mfv_sum / vol_sum\n" + " def populate_indicators(self, dataframe, metadata):\n" + " dataframe['cmf'] = self.chaikin_money_flow(\n" + " dataframe['high'], dataframe['low'], dataframe['close'],\n" + " dataframe['volume'], timeperiod=20,\n" + " )\n" + " return dataframe\n", + encoding="utf-8", + ) + + program = compile_indicator_program(source, class_name="RollingChaikin") + + call = next(node for node in program["nodes"] if node["op"] == "indicator-call") + assert call["parameters"] == { + "family": "native", + "name": "chaikin-money-flow", + "arguments": {"timeperiod": 20}, + } + validate_indicator_program(program) + + def test_indicator_program_unrolls_tuple_of_source_ordered_dynamic_mappings( tmp_path: Path, ) -> None: