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
5 changes: 5 additions & 0 deletions pineforge_codegen/codegen/emit_top.py
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,11 @@ class member but its initializer was dropped, leaving the member ``na``
if (is_drawing or is_udt) and isinstance(init_ast, NaLiteral):
continue
init_cpp = self._visit_expr(init_ast)
# A bare-``na`` initializer for an int/int64_t/bool ``var`` member
# must be typed (``na<int>()``), mirroring the class-scope ctor
# init (``_typed_na_init`` at _emit_constructor); a raw ``na<double>()``
# NaN stored into an int member is UB and defeats is_na<T>().
init_cpp = self._typed_na_init(init_cpp, name, ptype)
init_lines.append(f" {target} = {init_cpp};")
if not init_lines:
return
Expand Down
53 changes: 53 additions & 0 deletions pineforge_codegen/codegen/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,59 @@ def _is_int64_builtin_init(self, name: str) -> bool:
return True
return name in self._int64_reassign_targets()

def _na_reassign_cpp_type(self, name: str) -> str | None:
"""Declared scalar C++ type of a ``:=`` reassignment target ``name``, so a
bare-``na`` RHS (``x := na``) can be spelled ``na<T>()`` matching the
member/local type instead of the default ``na<double>()``.

Assigning a double quiet-NaN into an ``int``/``int64_t``/``bool`` member is
undefined behaviour (NaN->int is unspecified; on ARM64 it saturates to 0,
which is not the ``na<T>()`` sentinel) and defeats ``is_na<T>()``. Mirrors
the member-declaration type logic (``base._emit_class_members`` /
``_typed_na_init``): ``PINE_TYPE_TO_CPP`` plus the int->int64_t epoch
promotion. Returns ``None`` for collections / UDT / drawing handles and
for ``double`` (already the default lowering), so those paths are
unchanged.
"""
# Collections / UDT / drawing handles never take a scalar ``na<T>()``:
# leave them to the drawing-na / default lowering in _visit_rhs_value.
if (name in self._array_vars
or name in self._map_vars
or name in getattr(self, "_matrix_specs", {})
or name in self._udt_var_types):
return None
cpp_type: str | None = None
# 1. ``var`` member (class-scope OR function-local: both are recorded in
# ctx.var_members). This is the authoritative declaration source.
for vname, ptype, _init in self.ctx.var_members:
if vname == name:
cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double")
break
# 2. Function-local plain (non-``var``) scalar: its declared type was
# remembered at the VarDecl (``_type_for_decl``).
if cpp_type is None:
cpp_type = getattr(self, "_current_func_local_types", {}).get(name)
# 3. Function parameter.
if cpp_type is None:
cpp_type = getattr(self, "_current_func_param_types", {}).get(name)
# 4. Global-scope non-``var`` class member.
if cpp_type is None:
for gname, gptype in self.ctx.global_var_decls:
if gname == name:
cpp_type = PINE_TYPE_TO_CPP.get(gptype, "double")
break
if cpp_type is None:
return None
# int -> int64_t promotion for epoch-ms builtins, mirroring the member
# declaration so the na sentinel width matches the storage width.
if cpp_type == "int" and self._is_int64_builtin_init(name):
cpp_type = "int64_t"
# Only the retypeable scalar types are meaningful; ``double`` already
# lowers to ``na<double>()`` and everything else is left untouched.
if cpp_type in ("int", "int64_t", "bool", "std::string"):
return cpp_type
return None

# ------------------------------------------------------------------
# BUG C: user-defined-UDT lvalue aliasing
# ------------------------------------------------------------------
Expand Down
13 changes: 11 additions & 2 deletions pineforge_codegen/codegen/visit_stmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,14 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non
f"expected {self._type_spec_to_cpp(lhs_spec)}, "
f"got {self._type_spec_to_cpp(rhs_spec)}",
)
val_cpp = self._visit_rhs_value(node.value, target_name)
# A bare-``na`` reassignment must adopt the target's declared scalar
# type (``x := na`` -> ``na<int>()`` not ``na<double>()``); otherwise
# a double NaN is stored into an int/int64_t/bool member (UB, defeats
# is_na<T>()). Only computed for bare na — every other RHS is
# unaffected.
tct = (self._na_reassign_cpp_type(target_name)
if self._is_na_expr(node.value) else None)
val_cpp = self._visit_rhs_value(node.value, target_name, target_cpp_type=tct)
if node.op == ":=":
lines.append(f"{pad}{safe} = {val_cpp};")
else:
Expand All @@ -599,7 +606,9 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non
else:
lines.append(f"{pad}{safe} {node.op} {val_cpp};")
else:
val_cpp = self._visit_rhs_value(node.value, target_name)
tct = (self._na_reassign_cpp_type(target_name)
if self._is_na_expr(node.value) else None)
val_cpp = self._visit_rhs_value(node.value, target_name, target_cpp_type=tct)
if node.op == ":=":
lines.append(f"{pad}{safe} = {val_cpp};")
else:
Expand Down
183 changes: 183 additions & 0 deletions tests/test_na_reassign_retype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Regression: a bare-``na`` REASSIGNMENT (``x := na``) must lower to a typed
``na<T>()`` matching the target's declared C++ type, not the unconditional
``na<double>()``.

Root cause: ``visit_expr._visit_ident`` renders a bare ``na`` as
``na<double>()``. The *initializer* path retypes it (``_typed_na_init`` /
``_visit_rhs_value(target_cpp_type=...)``), but the ``:=`` reassignment path
called ``_visit_rhs_value`` WITHOUT a target type, so the retype branch never
fired and ``x := na`` emitted ``na<double>()`` for every LHS type.

Assigning a double quiet-NaN into an ``int``/``int64_t`` member is undefined
behaviour: on ARM64 ``FCVTZS(NaN) == 0`` (not the ``na<int64_t>()`` sentinel
``INT64_MIN``), so ``is_na()`` is false forever. Proven in
``gonzowiththewind-sisyphus-happiness``: an ``int64_t sessionOpenTime`` daily
open-suppress timer, reset with ``sessionOpenTime := na`` each new day, was
permanently disarmed because the reset stored ``na<double>()`` instead of
``na<int64_t>()``.
"""

from __future__ import annotations

import re

import pytest

from pineforge_codegen import transpile

from tests._compile import compile_cpp, have_compile_env


def _assign_rhs(cpp: str, lhs: str) -> list[str]:
"""Return the RHS of every ``<lhs> = <rhs>;`` statement (plain assignment,
not declaration / member-init-list) in the generated C++."""
out = []
pat = re.compile(rf"(?<![\w>])\b{re.escape(lhs)}\s*=\s*(na<[^;]*>\(\))\s*;")
for m in pat.finditer(cpp):
out.append(m.group(1))
return out


# --------------------------------------------------------------------------
# (a) class-scope var int, never holds time -> declared ``int`` -> na<int>()
# --------------------------------------------------------------------------
def test_class_scope_int_var_na_reassign_retypes_to_int():
src = """//@version=6
strategy("t")
var int slot = na
if close > open
slot := na
plot(na(slot) ? na : 1.0)
"""
cpp = transpile(src)
assert "int slot;" in cpp
assert "slot = na<int>();" in cpp, cpp
# The reassignment must NOT store a double NaN into an int member.
assert "slot = na<double>();" not in cpp


# --------------------------------------------------------------------------
# (d)/(gonzo) int var ALSO reassigned to ``time`` -> promoted to int64_t;
# BOTH the ``:= na`` reset AND the ``:= time`` set must use the int64 member.
# --------------------------------------------------------------------------
def test_int64_promoted_var_na_reassign_retypes_to_int64():
src = """//@version=6
strategy("t")
var int sessionOpenTime = na
newDay = ta.change(dayofweek) != 0
if newDay
sessionOpenTime := na
if not na(sessionOpenTime)
strategy.close("L")
if close > open
sessionOpenTime := time
plot(close)
"""
cpp = transpile(src)
assert "int64_t sessionOpenTime;" in cpp
# The na reset must match the promoted member type, not na<double>().
assert "sessionOpenTime = na<int64_t>();" in cpp, cpp
assert "sessionOpenTime = na<double>();" not in cpp


# --------------------------------------------------------------------------
# (b) function-local var int -> lifted to an int member; both the once-only
# init block AND the ``:= na`` reassignment must be typed.
# --------------------------------------------------------------------------
def test_function_local_int_var_na_reassign_retypes_to_int():
src = """//@version=6
strategy("t")
f() =>
var int et = na
if bar_index > 5
et := na
na(et) ? 0.0 : 1.0
plot(f())
"""
cpp = transpile(src)
assert "int et;" in cpp
# No double-NaN slip into the int member (init block OR reassignment).
assert "et = na<double>();" not in cpp, cpp
assert "et = na<int>();" in cpp


# --------------------------------------------------------------------------
# (c) float var -> declared double -> the na spelling stays na<double>()
# (guard against over-reach).
# --------------------------------------------------------------------------
def test_float_var_na_reassign_stays_double():
src = """//@version=6
strategy("t")
var float px = na
if close > open
px := na
plot(px)
"""
cpp = transpile(src)
assert "double px;" in cpp
assert "px = na<double>();" in cpp
assert "px = na<int>();" not in cpp
assert "px = na<int64_t>();" not in cpp


# --------------------------------------------------------------------------
# (e) bool var -> na<bool>()
# --------------------------------------------------------------------------
def test_bool_var_na_reassign_retypes_to_bool():
src = """//@version=6
strategy("t")
var bool flag = na
if close > open
flag := na
plot(na(flag) ? na : 1.0)
"""
cpp = transpile(src)
assert "bool flag;" in cpp
assert "flag = na<bool>();" in cpp, cpp
assert "flag = na<double>();" not in cpp


# --------------------------------------------------------------------------
# (f) faithful gonzo daily-open-timer reduction: the disarm bug in miniature.
# --------------------------------------------------------------------------
def test_gonzo_daily_open_timer_reduction():
src = """//@version=6
strategy("gonzo-lite")
var int sessionOpenTime = na
newDay = ta.change(dayofweek) != 0
inRTH = hour >= 9 and hour < 16
if newDay
sessionOpenTime := na
if inRTH and na(sessionOpenTime)
sessionOpenTime := time
secSinceOpen = na(sessionOpenTime) ? na : (time - sessionOpenTime) / 1000
if not na(secSinceOpen) and secSinceOpen > 3600
strategy.entry("L", strategy.long)
plot(close)
"""
cpp = transpile(src)
assert "int64_t sessionOpenTime;" in cpp
rhs = _assign_rhs(cpp, "sessionOpenTime")
# Every na reassignment of the timer must be int64-typed.
assert rhs, cpp
assert all(r == "na<int64_t>()" for r in rhs), rhs


# --------------------------------------------------------------------------
# Compile-level guard: the fixed gonzo-like emit is well-formed against the
# public engine headers (skips cleanly when the engine include is absent).
# --------------------------------------------------------------------------
@pytest.mark.skipif(not have_compile_env(), reason="engine include / compiler unavailable")
def test_int64_na_reassign_compiles():
src = """//@version=6
strategy("t")
var int sessionOpenTime = na
newDay = ta.change(dayofweek) != 0
if newDay
sessionOpenTime := na
if na(sessionOpenTime)
sessionOpenTime := time
plot(close)
"""
cpp = transpile(src)
compile_cpp(cpp, label="int64-na-reassign")
Loading