diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index d5d3538..a709538 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -183,6 +183,13 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._active_var_remap: dict[str, str] = {} # Set of var/series member names that belong to user functions (need cloning) self._func_var_members_set: set[str] = set() + # BUG C: function-local names emitted as ``UDT*`` pointer aliases (a UDT + # local initialised from a var/global UDT lvalue, mutated through, AND + # later rebound to a different lvalue). Member access lowers to ``->`` + # and rebinds to ``&(...)``. Reset per function in _emit_func_def is not + # needed: names are function-unique and the value-copy fallback ignores + # entries for inactive functions. + self._udt_ptr_alias_locals: set[str] = set() self._precalc_loop_active: bool = False # Names of ``var`` members that live in a FUNCTION scope (not global). # These are initialized once-per-function-variant on first call (a diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 1ae29d2..f03b19c 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -849,6 +849,18 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_call_site_idx = None prev_func_locals = self._current_func_locals + prev_func_body = getattr(self, "_current_func_body", None) + prev_func_name = getattr(self, "_active_func_name", None) + # The function body is the lexical scope used by the UDT-alias analysis + # (BUG C): a local initialised from a var/global UDT lvalue and later + # mutated through must alias, not value-copy. + self._current_func_body = node.body + self._active_func_name = fi.name + # Pointer-aliased UDT locals are function-scoped: a name like ``p_ivot`` + # may be a rebinding pointer alias in one function and a ``pivot&`` + # parameter in another, so reset per function to avoid cross-contamination. + prev_ptr_alias = self._udt_ptr_alias_locals + self._udt_ptr_alias_locals = set() self._current_func_locals = {n for n, _, _ in self.ctx.func_var_members.get(fi.name, [])} # Plain (non-persistent) scalar locals are emitted inline and live in # no other set; collect them so the unknown-identifier guard in @@ -917,6 +929,9 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._current_func_series_params = set() self._udt_param_udt = {} self._current_func_locals = prev_func_locals + self._current_func_body = prev_func_body + self._active_func_name = prev_func_name + self._udt_ptr_alias_locals = prev_ptr_alias self._active_ta_remap = {} self._active_var_remap = {} self._in_ta_func_variant = False diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 8531a21..5b3c645 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -1245,6 +1245,28 @@ def _collect_security_ta_indices(self, expr_node, resolving: set[str] | None = N ) return out + def _emit_security_ohlc_hist_pushes(self, sec_id: int, lines: list[str]) -> None: + """Emit the OHLC history-offset Series pushes for ``sec_id``, gated on + ``is_complete``. + + ``request.security(..., [high[1], low[1], ...], ...)`` reads HTF OHLC at + past-bar offsets. Each offset is backed by a per-field Series whose + history must advance once per COMPLETED HTF bar — not once per (partial) + chart-bar evaluation. ``_eval_security_N`` fires on every chart bar; only + the bar that completes the HTF aggregate has ``is_complete == true``. + Pushing unconditionally advanced the offset history every chart bar, so + ``high[1]`` resolved to a recent partial bar instead of the prior + completed HTF bar. Gate all pushes for this sec in one combined block.""" + fields = sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())) + if not fields: + return + lines.append(" if (is_complete) {") + for field in fields: + lines.append( + f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});" + ) + lines.append(" }") + def _emit_security_evaluators(self, lines: list[str]) -> None: """Emit _eval_security_N() methods and evaluate_security() dispatch.""" if not self._security_calls: @@ -1341,10 +1363,7 @@ def emit_security_ta(indices: list[int]) -> None: emitted_lines=lines, ) lines.append(f" _req_sec_{sec_id}_{i} = {el_cpp};") - for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())): - lines.append( - f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});" - ) + self._emit_security_ohlc_hist_pushes(sec_id, lines) lines.append(" }") lines.append("") continue @@ -1372,10 +1391,7 @@ def emit_security_ta(indices: list[int]) -> None: ) else: lines.append(f" _req_sec_{sec_id} = {expr_cpp};") - for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())): - lines.append( - f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});" - ) + self._emit_security_ohlc_hist_pushes(sec_id, lines) lines.append(" }") lines.append("") diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 1466803..243cb9d 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -255,7 +255,12 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: # must be promoted to ``int64_t`` so the ``na`` sentinel (``INT64_MIN``) # survives — narrowing to 32-bit ``int`` collapses it to ``0`` and breaks # ``is_na`` detection. -INT64_BUILTINS = {"time", "time_close", "timestamp", "time_tradingday"} +INT64_BUILTINS = {"time", "time_close", "timenow", "timestamp", "time_tradingday"} + +# Subset of INT64_BUILTINS spelled as a bare identifier (no call args) in Pine. +# ``entryTime := time`` parses the RHS as an ``Identifier`` named ``time`` (not a +# ``FuncCall``), so int64 promotion must match these by identifier name too. +INT64_BUILTIN_IDENTIFIERS = {"time", "time_close", "timenow"} PINE_TYPE_TO_CPP = { "int": "int", "float": "double", "bool": "bool", "string": "std::string", diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index 0f5afd5..a8a153e 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -430,25 +430,190 @@ def _series_type_for(self, name: str) -> str: return PINE_TYPE_TO_CPP.get(sym.pine_type, "double") return "double" + def _expr_is_int64_builtin(self, expr) -> bool: + """True if ``expr`` is a top-level int64-returning Pine builtin: either a + call to one of ``INT64_BUILTINS`` (``time(...)``, ``timestamp(...)``, …) + or a bare ``Identifier`` spelled like ``time`` / ``time_close`` / + ``timenow`` (which Pine exposes as a value, not a call).""" + from .tables import INT64_BUILTINS, INT64_BUILTIN_IDENTIFIERS + if expr is None: + return False + if isinstance(expr, FuncCall): + func_name, namespace = self._resolve_callee(expr.callee) + return namespace is None and func_name in INT64_BUILTINS + if isinstance(expr, Identifier): + return expr.name in INT64_BUILTIN_IDENTIFIERS + return False + + def _int64_reassign_targets(self) -> set[str]: + """Names of vars that are reassigned (``:=``/``=``) anywhere in the AST + with an RHS that is a top-level int64-returning builtin. Cached on the + instance. Pine ``int`` collapses these to 32-bit, but the runtime stores + the epoch in 64 bits, so the member must be promoted to ``int64_t``.""" + cached = getattr(self, "_int64_reassign_cache", None) + if cached is not None: + return cached + from ..ast_nodes import Assignment + targets: set[str] = set() + ast = getattr(self.ctx, "ast", None) + if ast is not None: + for node in self._walk_ast(ast): + if (isinstance(node, Assignment) + and isinstance(node.target, Identifier) + and self._expr_is_int64_builtin(node.value)): + targets.add(node.target.name) + self._int64_reassign_cache = targets + return targets + def _is_int64_builtin_init(self, name: str) -> bool: - """True if ``name``'s defining expression is a top-level call to a - Pine builtin that returns ``int64_t`` (``time``, ``time_close``, - ``timestamp``). The Pine type system collapses these to ``int`` - but the engine encodes the ``na`` sentinel in the upper 32 bits, - so storing into ``Series`` would silently corrupt na detection. + """True if ``name``'s initializer OR any ``:=``/``=`` reassignment has an + RHS that is a top-level int64-returning builtin (``time``, ``time_close``, + ``timenow``, ``timestamp``, ``time_tradingday``). The Pine type system + collapses these to ``int`` but the engine encodes the ``na`` sentinel + (and the full epoch-ms value, which overflows int32) in 64 bits, so + storing into ``int`` silently corrupts both the value and na detection. + A reassignment like ``var int entryTime = na`` then ``entryTime := time`` + must promote even though the *initializer* alone is ``na``. """ - from .tables import INT64_BUILTINS expr = ( self.ctx.global_expr_map.get(name) or self.ctx.var_member_init_exprs.get(name) ) - if expr is None: - return False - if isinstance(expr, FuncCall): - func_name, namespace = self._resolve_callee(expr.callee) - if namespace is None and func_name in INT64_BUILTINS: - return True - return False + if self._expr_is_int64_builtin(expr): + return True + return name in self._int64_reassign_targets() + + # ------------------------------------------------------------------ + # BUG C: user-defined-UDT lvalue aliasing + # ------------------------------------------------------------------ + + def _is_udt_lvalue(self, expr) -> str | None: + """If ``expr`` is a *user-defined* UDT lvalue (a bare ``Identifier`` that + names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``), + return its UDT type name; else ``None``. + + Pine UDTs are reference types, so a local initialised from such an lvalue + and then mutated through must write back to the global. Drawing UDTs are + handled by the separate ``_uses_drawing`` path and are excluded here.""" + if not isinstance(expr, Identifier): + return None + udt_t = self._udt_var_types.get(expr.name) + if udt_t is None or udt_t not in self._udt_defs: + return None + if udt_t in DRAWING_TYPE_TO_CPP: + return None + # Must be a known global/class-scope member (not a function param or a + # plain local snapshot) for write-through to be observable. + if expr.name in getattr(self, "_current_func_locals", set()): + # A function-local of UDT type that is itself a persistent ``var`` + # member still write-through aliases; but a plain inline local does + # not represent shared state. Only treat ``var`` func-locals (in + # func_var_members) as aliasable shared state. + fname = getattr(self, "_active_func_name", None) + var_locals = {n for n, _, _ in self.ctx.func_var_members.get(fname, [])} if fname else set() + if expr.name not in var_locals: + return None + return udt_t + + def _udt_lvalue_selection_type(self, expr) -> str | None: + """UDT type if ``expr`` is a UDT lvalue OR a ternary/switch whose every + selectable branch is a UDT lvalue of the SAME user-defined UDT type. + Returns ``None`` otherwise (so plain ``UDT a = b`` value-snapshots, calls, + ``.new(...)`` ctors, and mixed/non-lvalue selections never alias).""" + direct = self._is_udt_lvalue(expr) + if direct is not None: + return direct + branches: list = [] + if isinstance(expr, Ternary): + branches = [expr.true_val, expr.false_val] + elif isinstance(expr, SwitchStmt): + for _case_expr, stmts in (expr.cases or []): + if not stmts: + return None + last = stmts[-1] + branches.append(last.expr if isinstance(last, ExprStmt) else last) + if expr.default_body: + last = expr.default_body[-1] + branches.append(last.expr if isinstance(last, ExprStmt) else last) + else: + return None + if not branches: + return None + types = {self._is_udt_lvalue(b) for b in branches} + if len(types) == 1 and None not in types: + return next(iter(types)) + return None + + def _udt_local_alias_kind(self, node: VarDecl) -> tuple[str, str] | None: + """Decide whether a hintless/typed local UDT declaration must ALIAS the + global(s) it selects rather than value-copy (BUG C). + + Returns ``("ref", udt_type)`` for a non-rebinding reference alias, + ``("ptr", udt_type)`` for a pointer alias (the local is later reassigned + to a *different* UDT lvalue, which a C++ reference cannot do), or + ``None`` to keep the existing value-copy semantics. + + Conditions (all required): + * RHS is a UDT lvalue or a ternary/switch selecting same-typed UDT + lvalues (``_udt_lvalue_selection_type``). + * The local is MUTATED later in the enclosing function body + (``local.field := ...``) — a pure read-only snapshot needn't alias. + + The mutation requirement is the safety guard: a local that is only read + keeps value semantics, and a local initialised from a non-lvalue (a + ``.new()`` ctor, a function return, or a plain local copy) returns + ``None`` here, preserving intentional independent-copy semantics.""" + from ..ast_nodes import Assignment + body = getattr(self, "_current_func_body", None) + if body is None: + return None + udt_t = self._udt_lvalue_selection_type(node.value) + if udt_t is None: + return None + name = node.name + mutated = False + rebinds_to_other_lvalue = False + for stmt in self._walk_ast_list(body): + if not isinstance(stmt, Assignment): + continue + tgt = stmt.target + # Mutation through the local: ``p.field := ...`` + if (isinstance(tgt, MemberAccess) + and isinstance(tgt.object, Identifier) + and tgt.object.name == name): + mutated = True + # Rebind of the local itself to another UDT lvalue: ``p := other`` + elif isinstance(tgt, Identifier) and tgt.name == name: + if self._udt_lvalue_selection_type(stmt.value) is not None: + rebinds_to_other_lvalue = True + else: + # Reassigned to a non-lvalue (e.g. ``.new()`` / a copy): + # aliasing would be wrong; bail to value-copy. + return None + if not mutated: + return None + return ("ptr" if rebinds_to_other_lvalue else "ref"), udt_t + + def _walk_ast_list(self, stmts): + """Yield every node within a list of statements (depth-first).""" + for s in stmts: + yield from self._walk_ast(s) + + def _addr_of_udt_selection(self, expr, local_name: str): + """Render the address-of form of a UDT lvalue selection for a pointer + alias (BUG C rebind case): ``other`` -> ``&(other)``; + ``cond ? a : b`` -> ``(cond ? &(a) : &(b))``. The selectable branches are + guaranteed (by ``_udt_lvalue_selection_type``) to be UDT lvalues.""" + if isinstance(expr, Identifier): + return f"&({self._safe_name(expr.name)})" + if isinstance(expr, Ternary): + cond = self._visit_expr(expr.condition) + t = self._addr_of_udt_selection(expr.true_val, local_name) + f = self._addr_of_udt_selection(expr.false_val, local_name) + return f"({cond} ? {t} : {f})" + # Switch selection: lower to nested ternaries over case equality. Rare in + # practice; fall back to address-of the whole lowered expression. + return f"&({self._visit_expr(expr)})" def _infer_cpp_type_for_security_elem(self, node) -> str: """C++ type for one element of the ``request.security(..., expr, ...)`` payload. diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index ec4cbec..4ac22ba 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -1061,6 +1061,17 @@ def _visit_arg_for_series(arg_node, arg_idx): all_args.extend(self._visit_expr(v) for v in node.kwargs.values()) else: all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)] + # Drawing-style/visual CONSTANT passed positionally into a user function's + # ``string`` parameter: ``label.style_*`` / ``size.*`` / other + # DRAWING_STYLE_NS members lower to the bare token ``"0"`` (they only ever + # feed dropped visual kwargs). Bound to a ``std::string`` parameter, that + # ``0`` constructs ``std::string((char const*)0)`` at the call site -> a + # null-pointer ``strlen`` crash at runtime. Coerce such args to + # ``std::string("")`` so the (inert, visual-only) value is a valid empty + # string. Only touches user functions with a known string param and an + # arg that is exactly such a drawing-style constant read. + if namespace is None and func_name in self._func_names: + self._coerce_drawing_style_string_args(func_name, node.args, all_args) # Default args (parser does not store defaults): isInSession(sess, res = timeframe.period) if namespace is None and func_name in self._func_names: fi = self._func_info_map.get(func_name) @@ -1089,6 +1100,32 @@ def _visit_arg_for_series(arg_node, arg_idx): emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}" return f"{prefix}{emit_name}({', '.join(all_args)})" + def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> None: + """In-place coerce positional args bound to a ``std::string`` user-function + parameter that lowered to the bare token ``"0"`` from a drawing-style / + visual constant (``label.style_*`` etc.). Such a literal ``0`` binds as + ``std::string((char const*)0)`` and segfaults on first use. Replace with + ``std::string("")`` (the value is visual-only and inert in a backtest).""" + from .tables import DRAWING_STYLE_NS + fi = self._func_info_map.get(func_name) + if not fi or not getattr(fi, "node", None) or not fi.node.params: + return + specs = getattr(fi, "param_type_specs", []) or [] + for i, arg in enumerate(arg_nodes): + if i >= len(all_args) or all_args[i] != "0": + continue + # Only when the destination parameter is a string. + spec = specs[i] if i < len(specs) else None + is_string_param = spec is not None and getattr(spec, "kind", None) == "primitive" \ + and getattr(spec, "name", None) == "string" + if not is_string_param: + continue + # Only when the source really is a drawing-style/visual constant read + # (so we never silently turn a numeric ``0`` into an empty string). + if (isinstance(arg, MemberAccess) and isinstance(arg.object, Identifier) + and arg.object.name in DRAWING_STYLE_NS): + all_args[i] = 'std::string("")' + def _visit_fixnan(self, node: FuncCall) -> str: """Emit fixnan with persistent state member.""" self._fixnan_counter += 1 diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 6b2eb30..5fb35d2 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -714,6 +714,10 @@ def _visit_member_access(self, node: MemberAccess) -> str: safe = self._safe_name(name) if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] + # Pointer-aliased UDT local (BUG C, rebinding case): field access + # goes through ``->`` since the local holds ``UDT*``. + if name in self._udt_ptr_alias_locals: + return f"{safe}->{node.member}" return f"{safe}.{node.member}" if name not in self.ctx.series_vars: # Unknown identifier — likely an enum value diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 81a2fc9..93db171 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -402,6 +402,26 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: self._visit_if_switch_expr(node.value, safe, lines, indent) return + # UDT lvalue alias (BUG C): a local initialised from a user-defined-UDT + # var/global lvalue (or a ternary/switch of such lvalues) and then + # mutated through must ALIAS the global, not value-copy — Pine UDTs are + # reference types. Emit a C++ reference (non-rebinding) or pointer + # (rebinding) alias instead of the default copy. + if not is_global_member: + alias = self._udt_local_alias_kind(node) + if alias is not None: + kind, udt_t = alias + if kind == "ref": + cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=udt_t) + lines.append(f"{pad}{udt_t}& {safe} = {cpp_val};") + return + # Pointer alias: take address of each selected lvalue; subsequent + # field access lowers to ``->`` and rebinds to ``&(other)``. + self._udt_ptr_alias_locals.add(node.name) + cpp_val = self._addr_of_udt_selection(node.value, node.name) + lines.append(f"{pad}{udt_t}* {safe} = {cpp_val};") + return + # General declaration cpp_type = self._type_for_decl(node) if not is_global_member else None cpp_val = self._visit_rhs_value(node.value, node.name, target_cpp_type=cpp_type) @@ -477,6 +497,12 @@ def _visit_assignment(self, node: Assignment, lines: list[str], pad: str) -> Non if self._active_var_remap and safe in self._active_var_remap: safe = self._active_var_remap[safe] + # Pointer-aliased UDT local (BUG C, rebinding case): ``p := other`` + # rebinds the pointer to the address of the newly selected UDT lvalue. + if target_name in self._udt_ptr_alias_locals and node.op == ":=": + lines.append(f"{pad}{safe} = {self._addr_of_udt_selection(node.value, target_name)};") + return + if target_name in self.ctx.series_vars: val_cpp = self._visit_expr(node.value) if node.op == ":=": diff --git a/tests/test_int64_reassign_promotion.py b/tests/test_int64_reassign_promotion.py new file mode 100644 index 0000000..8a255dd --- /dev/null +++ b/tests/test_int64_reassign_promotion.py @@ -0,0 +1,73 @@ +"""Regression (BUG A): int->int64_t promotion must consider REASSIGNMENTS. + +Pine ``var int entryTime = na`` then ``entryTime := time`` stores an epoch-ms +value (~1.74e12) that overflows 32-bit ``int``. The promotion guard only +inspected the *initializer* (here ``na``), not the ``:= time`` reassignment, so +the member was emitted as ``int`` and downstream time math (e.g. MaxHold) misfired. + +``time`` (and ``time_close`` / ``timenow``) are bare ``Identifier`` builtins in +Pine, so the guard must also match an identifier RHS, not only a ``FuncCall``. +""" + +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile + + +def test_var_int_reassigned_to_time_promotes_to_int64(): + src = """//@version=6 +strategy("t") +var int entryTime = na +if close > open + entryTime := time +plot(na(entryTime) ? na : 1.0) +""" + cpp = transpile(src) + # Member declared int64_t, ctor na-init re-typed. + assert "int64_t entryTime;" in cpp + assert "entryTime(na())" in cpp + # No 32-bit slip-through. + assert "int entryTime;" not in cpp.replace("int64_t entryTime;", "") + + +def test_local_int_reassigned_to_time_promotes_to_int64(): + src = """//@version=6 +strategy("t") +var int et = na +et := time +exitNow = na(et) ? false : (time - et > 1000) +if exitNow + strategy.close("L") +plot(close) +""" + cpp = transpile(src) + assert "int64_t et;" in cpp + assert "et(na())" in cpp + + +def test_timenow_identifier_reassign_promotes(): + src = """//@version=6 +strategy("t") +var int t0 = na +if barstate.isconfirmed + t0 := timenow +plot(na(t0) ? na : 1.0) +""" + cpp = transpile(src) + assert "int64_t t0;" in cpp + + +def test_int_var_never_reassigned_to_time_stays_int(): + # Control: a plain int counter must NOT be widened. + src = """//@version=6 +strategy("t") +var int counter = 0 +if close > open + counter := counter + 1 +plot(counter) +""" + cpp = transpile(src) + assert "int64_t counter" not in cpp + assert "int counter;" in cpp diff --git a/tests/test_security_ohlc_hist_is_complete.py b/tests/test_security_ohlc_hist_is_complete.py new file mode 100644 index 0000000..5e76905 --- /dev/null +++ b/tests/test_security_ohlc_hist_is_complete.py @@ -0,0 +1,55 @@ +"""Regression (BUG B): request.security OHLC history-offset pushes must gate on +``is_complete``. + +``request.security(tickerid, "D", [high[1], low[1], ...], ...)`` reads HTF OHLC +at past-bar offsets backed by per-field Series. ``_eval_security_N`` fires on +every (partial) chart bar; only the bar completing the HTF aggregate has +``is_complete == true``. The OHLC-history pushes were emitted unconditionally, +so the offset history advanced every chart bar instead of per completed HTF bar +— ``high[1]`` resolved to a recent partial bar, not the prior completed HTF bar. +The pushes must be wrapped in a single ``if (is_complete) { ... }`` block. +""" + +from __future__ import annotations + +import re + +from pineforge_codegen import transpile + + +def _eval_body(cpp: str) -> str: + m = re.search(r"void _eval_security_\d+.*?\n \}", cpp, re.S) + assert m is not None, "no _eval_security_N method found" + return m.group(0) + + +def test_tuple_ohlc_hist_pushes_gated_on_is_complete(): + src = """//@version=6 +strategy("t", overlay=true) +[h1, l1, h2, l2, a] = request.security(syminfo.tickerid, "D", [high[1], low[1], high[2], low[2], ta.atr(14)[1]], lookahead=barmerge.lookahead_on) +if not na(h1) and high > h1 + strategy.entry("L", strategy.long) +plot(close) +""" + body = _eval_body(transpile(src)) + # The HTF-history pushes must live inside an is_complete guard. + assert "if (is_complete) {" in body + # Every hist push for this sec sits inside the guard, not before it. + guard_idx = body.index("if (is_complete) {") + for push in re.finditer(r"_sec0_hist_\w+\.push\(bar\.\w+\);", body): + assert push.start() > guard_idx, f"ungated push: {push.group(0)}" + + +def test_scalar_ohlc_hist_push_gated_on_is_complete(): + src = """//@version=6 +strategy("t", overlay=true) +h1 = request.security(syminfo.tickerid, "D", high[1], lookahead=barmerge.lookahead_on) +if not na(h1) and high > h1 + strategy.entry("L", strategy.long) +plot(close) +""" + body = _eval_body(transpile(src)) + assert "if (is_complete) {" in body + guard_idx = body.index("if (is_complete) {") + push = re.search(r"_sec0_hist_high\.push\(bar\.high\);", body) + assert push is not None and push.start() > guard_idx diff --git a/tests/test_udt_lvalue_alias.py b/tests/test_udt_lvalue_alias.py new file mode 100644 index 0000000..0fc2be2 --- /dev/null +++ b/tests/test_udt_lvalue_alias.py @@ -0,0 +1,127 @@ +"""Regression (BUG C): UDT lvalue init must ALIAS, not value-copy. + +Pine UDTs are reference types. A local initialised from a var/global UDT lvalue +(or a ternary/switch selecting such lvalues) and then mutated through must write +back to the global. Codegen previously emitted a value copy, so the mutation was +lost. The fix emits a C++ reference (non-rebinding) or pointer (rebinding) alias. + +Also covers the related crash unmasked by the alias fix: a drawing-style visual +constant (``label.style_*``) passed positionally into a user function's +``string`` parameter lowered to a bare ``0`` -> ``std::string((char const*)0)`` +-> null ``strlen`` SEGV. It must coerce to ``std::string("")``. +""" + +from __future__ import annotations + +import re + +from pineforge_codegen import transpile + + +def _func_body(cpp: str, name: str) -> str: + m = re.search(rf"{re.escape(name)}\w*\(.*?\n \}}", cpp, re.S) + assert m is not None, f"function {name} not found" + return m.group(0) + + +PROLOGUE = """//@version=6 +strategy("t") +type pivot + float currentLevel + bool crossed +var pivot gLow = pivot.new(na, false) +var pivot gHigh = pivot.new(na, false) +""" + + +def test_ternary_of_var_udts_mutated_emits_reference_alias(): + src = PROLOGUE + """ +upd(bool internal) => + pivot p = internal ? gLow : gHigh + p.currentLevel := low + p.crossed := false + 0 +if close > open + upd(true) +plot(close) +""" + cpp = transpile(src) + body = _func_body(cpp, "upd") + # Reference alias (non-rebinding), not a value copy. + assert re.search(r"pivot& p = \(\(internal\) \? \(gLow\) : \(gHigh\)\);", body) + assert "pivot p = ((internal)" not in body + # Mutations write through the reference with ``.`` member access. + assert "p.currentLevel = " in body + assert "p.crossed = " in body + + +def test_rebound_udt_local_emits_pointer_alias(): + src = PROLOGUE + """ +disp(bool internal) => + pivot p = internal ? gHigh : gLow + p.crossed := true + p := internal ? gLow : gHigh + p.crossed := true + 0 +if close > open + disp(false) +plot(close) +""" + cpp = transpile(src) + body = _func_body(cpp, "disp") + # Pointer alias because the local is rebound to a different lvalue. + assert re.search(r"pivot\* p = \(internal \? &\(gHigh\) : &\(gLow\)\);", body) + # Field access goes through ``->`` and the rebind takes the address. + assert "p->crossed = true;" in body + assert re.search(r"p = \(internal \? &\(gLow\) : &\(gHigh\)\);", body) + + +def test_value_snapshot_not_aliased(): + # CAUTION control: a local copied from a .new() ctor (not a var/global + # lvalue) and mutated must stay a VALUE copy, never an alias. + src = PROLOGUE + """ +mk() => + pivot p = pivot.new(1.0, false) + p.currentLevel := 2.0 + p.currentLevel +v = mk() +plot(v) +""" + cpp = transpile(src) + body = _func_body(cpp, "mk") + assert "pivot& p" not in body + assert "pivot* p" not in body + assert "pivot p = " in body + + +def test_readonly_var_udt_local_not_aliased(): + # A local read but never mutated needn't alias (value-copy is fine and + # avoids surprising reference semantics for pure reads). + src = PROLOGUE + """ +rd(bool internal) => + pivot p = internal ? gLow : gHigh + p.currentLevel +x = rd(true) +plot(x) +""" + cpp = transpile(src) + body = _func_body(cpp, "rd") + assert "pivot& p" not in body + assert "pivot* p" not in body + + +def test_drawing_style_constant_into_string_param_coerced(): + # The crash unmasked by the alias fix: label.style_* into a string param. + src = """//@version=6 +strategy("t") +draw(string labelStyle) => + label.new(bar_index, close, "x", style=labelStyle) + 0 +if close > open + draw(label.style_label_down) +plot(close) +""" + cpp = transpile(src) + assert 'draw(std::string(""))' in cpp + # No bare null-pointer string construction. + assert "draw(0)" not in cpp