Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/releases/v1.6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions python/nfi_backtest_engine/_indicator_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
41 changes: 37 additions & 4 deletions python/nfi_backtest_engine/indicator_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 (
Expand All @@ -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"]], {}


Expand Down
70 changes: 70 additions & 0 deletions tests/test_indicator_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down