diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index 0befe92..130e06e 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -143,6 +143,13 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._block_var_seq = 0 self._ta_counter = 0 self._fixnan_counter = 0 + # All fixnan member names minted so far (base + clones), for O(1) + # collision detection when minting a per-call-site fixnan clone. + self._fixnan_member_names: set[str] = set() + # Authoritative fixnan clone-name map for collisions: (func, cs_idx) + # -> {orig_member: cloned_member}. Consumed verbatim by the codegen + # when the default ``{base}_cs{cs_idx}`` formula would collide. + self._func_cs_fixnan_clone_names: dict[tuple[str, int], dict[str, str]] = {} # Track user-defined function nodes for deferred analysis self._func_defs: dict[str, FuncDef] = {} # Track user-defined function return types @@ -164,6 +171,12 @@ def __init__(self, ast: Program, filename: str = "") -> None: self._func_ta_ranges: dict[str, tuple[int, int]] = {} # func_name -> (start, end) indices self._func_call_site_count: dict[str, int] = {} # func_name -> count self._func_call_cs_map: dict[int, tuple[str, int]] = {} # call_node_id -> (func_name, cs_idx) + # Per-function fixnan site ownership: func_name -> list of fixnan site + # indices in self._fixnan_sites owned by that function. Mirrors the + # TA-range slicing but for fixnan state, so per-call-site cloning can + # mint fresh fixnan members per variant and the codegen dead-code pass + # can skip fixnan state owned by dead functions. + self._func_fixnan_indices: dict[str, list[int]] = {} # Functions whose ONLY reason for needing per-call-site body cloning # is a security-tf-monomorphized request.security (no TA/series state # of their own — see _check_mixed_callsite_security_tf). @@ -201,6 +214,13 @@ def __init__(self, ast: Program, filename: str = "") -> None: # a TA ctor length with one of the OUTER function's params, so the outer # call site can re-substitute (e.g. f_bbwp(_bbwLen) -> f_basisMa(_len)). self._enclosing_func_params: list[set[str]] = [] + # Parallel stack of the function NAMES whose param-sets are in + # ``_enclosing_func_params``. The top of stack (or None at global + # scope) is the owner of any ORIGINAL ``ta.*`` site minted right now + # -- recorded on ``TACallSite.owner_func`` so the codegen dead-code + # pass can tell borrowed clones apart from a dead function's own + # sites (see contracts.TACallSite.owner_func). + self._enclosing_func_names: list[str] = [] # Set of TA-site indices a nested user-func call rewrote in terms of the # current enclosing function's params (None when not inside a FuncDef body). self._nested_ta_touched: set | None = None @@ -303,6 +323,8 @@ def analyze(self) -> AnalyzerContext: var_members=self._var_members, func_infos=self._func_infos, fixnan_sites=self._fixnan_sites, + func_fixnan_indices=self._func_fixnan_indices, + func_cs_fixnan_clone_names=self._func_cs_fixnan_clone_names, strategy_params=self._strategy_params, diagnostics=self._diagnostics, filename=self._filename, @@ -1163,6 +1185,7 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: old_global = self._global_scope self._global_scope = False self._enclosing_func_params.append(set(node.params)) + self._enclosing_func_names.append(node.name) self._nested_ta_touched = set() try: for stmt in node.body: @@ -1170,6 +1193,7 @@ def _visit_FuncDef(self, node: FuncDef) -> PineType: finally: self._global_scope = old_global self._enclosing_func_params.pop() + self._enclosing_func_names.pop() nested_touched = self._nested_ta_touched self._nested_ta_touched = None diff --git a/pineforge_codegen/analyzer/call_handlers.py b/pineforge_codegen/analyzer/call_handlers.py index 11ef1c0..cf90daf 100644 --- a/pineforge_codegen/analyzer/call_handlers.py +++ b/pineforge_codegen/analyzer/call_handlers.py @@ -67,7 +67,7 @@ from ..ast_nodes import ( ASTNode, BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, - NumberLiteral, StringLiteral, TupleLiteral, UnaryOp, VarDecl, + NumberLiteral, StringLiteral, Ternary, TupleLiteral, UnaryOp, VarDecl, ) from ..symbols import PineType from .. import signatures as sigs @@ -205,6 +205,7 @@ def _handle_ta_call(self, func_name: str, node: FuncCall) -> PineType: returns_tuple=returns_tuple, node=node, is_static=is_static, + owner_func=(self._enclosing_func_names[-1] if self._enclosing_func_names else None), ) self._ta_call_sites.append(site) self._ta_member_names.add(site.member_name) @@ -253,6 +254,7 @@ def _handle_ta_call(self, func_name: str, node: FuncCall) -> PineType: returns_tuple=returns_tuple, node=node, is_static=is_static, + owner_func=(self._enclosing_func_names[-1] if self._enclosing_func_names else None), ) self._ta_call_sites.append(site) self._ta_member_names.add(site.member_name) @@ -811,11 +813,18 @@ def _handle_fixnan_call(self, node: FuncCall) -> PineType: arg_type = self._visit(arg) self._fixnan_counter += 1 + owner = self._enclosing_func_names[-1] if self._enclosing_func_names else None site = FixnanCallSite( member_name=f"_prev_fixnan_{self._fixnan_counter}", pine_type=arg_type, + node=node, + owner_func=owner, ) + idx = len(self._fixnan_sites) self._fixnan_sites.append(site) + self._fixnan_member_names.add(site.member_name) + if owner is not None: + self._func_fixnan_indices.setdefault(owner, []).append(idx) return arg_type @@ -843,13 +852,30 @@ def _is_arith(n) -> bool: return _is_arith(n.left) and _is_arith(n.right) if isinstance(n, UnaryOp): return _is_arith(n.operand) + if isinstance(n, Ternary): + # ``cond ? a : b`` — expand when both branches are arith. + # The condition may be a comparison/logical over arith leaves; + # rely on the codegen stability gate to reject series deps. + return (_is_arith(n.true_val) and _is_arith(n.false_val) + and _is_arith(n.condition)) + if isinstance(n, MemberAccess): + # ``timeframe.*`` / ``syminfo.*`` / ``math.pi`` etc. — stable + # per-run scalars that may appear inside a function-local + # derived length. Let the codegen stability classifier decide. + return True if isinstance(n, FuncCall): - # Allow math.* helpers (math.round/sqrt/...) over arith args. callee = n.callee + # Allow math.* helpers (math.round/sqrt/cos/...) over arith args. if (isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier) and callee.object.name == "math"): return all(_is_arith(a) for a in n.args) + # Pine type-cast builtins ``int(x)`` / ``float(x)`` / ``bool(x)`` + # / ``string(x)`` — transparent over arith args. Common in + # derived TA lengths (``int(math.round(2 / a))``). + if (isinstance(callee, Identifier) + and callee.name in ("int", "float", "bool", "string")): + return all(_is_arith(a) for a in n.args) return False reassigned: set[str] = set() @@ -1063,12 +1089,52 @@ def _rep(m: re.Match) -> str: returns_tuple=orig.returns_tuple, node=orig.node, is_static=orig.is_static, + # The clone belongs to the CALLEE's per-call-site + # namespace (``func_name``), NOT the caller whose + # body visit minted it. The codegen dead-code pass + # uses owner_func to decide whether to drop the + # declaration: a clone owned by a live callee must + # survive even when minted during a dead caller's + # body visit (regression: quantbyboji DMI). + owner_func=func_name, ) self._ta_call_sites.append(cloned) self._ta_member_names.add(clone_name) if clone_name_map: self._func_cs_ta_clone_names[(func_name, cs_idx)] = clone_name_map + # Clone fixnan sites for cs_idx > 0 so each emitted variant of + # this function references its OWN previous-value member (two + # call sites must not share fixnan state -- the second would + # otherwise read the first's last non-na value). cs0 keeps the + # originals. The clone name follows the ``{orig}_cs{cs_idx}`` + # formula the codegen re-derives (mirrors TA clone naming). + fn_indices = self._func_fixnan_indices.get(func_name, []) + if cs_idx > 0 and fn_indices: + clone_map: dict[str, str] = {} + for fi in fn_indices: + orig = self._fixnan_sites[fi] + fn_clone_name = f"{orig.member_name}_cs{cs_idx}" + # Disambiguate collisions (a fixnan site reached through + # >1 enclosing function could collide on the formula). + if fn_clone_name in self._fixnan_member_names: + base = fn_clone_name + n = 2 + while fn_clone_name in self._fixnan_member_names: + fn_clone_name = f"{base}_u{n}" + n += 1 + clone_map[orig.member_name] = fn_clone_name + cloned_fn = FixnanCallSite( + member_name=fn_clone_name, + pine_type=orig.pine_type, + node=orig.node, + owner_func=func_name, + ) + self._fixnan_sites.append(cloned_fn) + self._fixnan_member_names.add(fn_clone_name) + if clone_map: + self._func_cs_fixnan_clone_names[(func_name, cs_idx)] = clone_map + # Create or update FuncInfo is_tuple = self._func_returns_tuple.get(func_name, False) tuple_count = self._func_tuple_element_count.get(func_name, 0) diff --git a/pineforge_codegen/analyzer/contracts.py b/pineforge_codegen/analyzer/contracts.py index 08c5c4c..167da77 100644 --- a/pineforge_codegen/analyzer/contracts.py +++ b/pineforge_codegen/analyzer/contracts.py @@ -37,6 +37,20 @@ class TACallSite: returns_tuple: bool # e.g., MACD, supertrend node: Any = None # the FuncCall AST node is_static: bool = False # true if global scope & arguments are recursively static + # Name of the user function that OWNS this site, or None for a top-level + # site. For an ORIGINAL site this is the function whose body textually + # contains the ``ta.*`` call. For a CLONE minted in + # ``_handle_user_func_call`` this is the CALLEE (the function being + # called, NOT the caller whose body visit triggered the clone) -- the + # clone belongs to the callee's per-call-site namespace. + # + # The codegen's dead-code pass keys off this rather than off + # ``func_ta_ranges`` slices, because a function's slice can include + # clones of ANOTHER (live) function's sites (minted while visiting a + # caller's body). Marking such a borrowed clone dead would leave the + # owning callee's emitted clone body referencing undeclared members + # (regression: quantbyboji-nq-hma-midday ``_ta_change_*_cs1``). + owner_func: str | None = None @dataclass @@ -87,6 +101,13 @@ class FixnanCallSite: """Per-call-site state for ``fixnan(...)`` (one previous-value member each).""" member_name: str # e.g., "_prev_fixnan_1" pine_type: Any # PineType + node: Any = None # the FuncCall AST node (for variant-aware lookup) + # Name of the user function that OWNS this site, or None for a top-level + # site. Mirrors ``TACallSite.owner_func``: the codegen's dead-code pass + # and per-variant clone logic key off this so a fixnan site minted inside + # a dead caller's body but cloned for a live callee survives, and each + # emitted function variant references its OWN fixnan member. + owner_func: str | None = None @dataclass @@ -164,6 +185,14 @@ class AnalyzerContext: var_members: list = field(default_factory=list) # [(name, PineType, init_expr_str)] func_infos: list = field(default_factory=list) fixnan_sites: list = field(default_factory=list) + # Per-function fixnan site ownership (func_name -> list of indices into + # ``fixnan_sites``). Used by the codegen to clone fixnan members per + # call-site variant and to skip fixnan state owned by dead functions. + func_fixnan_indices: dict = field(default_factory=dict) + # (func_name, cs_idx) -> {orig_member_name: cloned_member_name}. Like + # ``func_cs_ta_clone_names`` but for fixnan: populated only when the + # default ``{base}_cs{cs_idx}`` clone name collides. + func_cs_fixnan_clone_names: dict = field(default_factory=dict) strategy_params: dict = field(default_factory=dict) diagnostics: list = field(default_factory=list) # warnings filename: str = "" diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 7561308..3b0ff36 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -338,10 +338,28 @@ def __init__(self, ctx: AnalyzerContext) -> None: self._instance_dispatch: dict[tuple[str | None, int], str] = {} self._fresh_instances: list[dict] = [] self._fresh_var_members: list[tuple[str, str]] = [] + # Fresh fixnan members for context-sensitive helper instances (nested + # helpers reached through >1 distinct call path). Each fresh instance + # gets its OWN previous-value member so two paths never share fixnan + # state. Populated by ``_build_func_instances``; declared in step 7. + self._fresh_fixnan_members: list[tuple[Any, str]] = [] # NOTE: _build_func_instances() runs at the top of generate() (it needs # _all_member_names / _func_safe_name, which are populated later in __init__). # Build lookup: node id -> FixnanCallSite (counter-based) self._fixnan_counter = 0 + # Per-call-site fixnan member remap for user functions (mirrors the TA + # remap): (func_name, cs_idx) -> {orig_member: cloned_member}. + self._func_cs_fixnan_remap: dict[tuple[str, int], dict[str, str]] = {} + # Active fixnan remap (set during per-call-site function emission). + self._active_fixnan_remap: dict[str, str] = {} + # node id -> original FixnanCallSite (the cs0 / source-level site). + self._fixnan_site_map: dict[int, Any] = {} + # Set of fixnan member names that belong to user functions (excluded + # from the site map so the active remap can dispatch per variant). + self._func_fixnan_members: set[str] = set() + # Dead fixnan site indices (owner is a dead user function). Skipped + # at declaration time so dead functions' fixnan state is not emitted. + self._dead_fixnan_indices: set[int] = set() self._switch_counter = 0 self._security_inline_counter = 0 self._random_call_counter = 0 @@ -377,9 +395,25 @@ def __init__(self, ctx: AnalyzerContext) -> None: # ctor-init list still folds to the Pine-default literal via _resolve_known. self._derived_input_expr: dict[str, str] = {} self._timeframe_period_vars: set[str] = set() + # Names of class-scope vars whose value is a bar-invariant scalar — + # i.e. derived only from inputs, literals, ``timeframe.*`` members, + # ``math.*`` over stable args, and ternaries/casts/arithmetic over + # any of those. Such vars are safe to embed in a TA ctor runtime + # reset expression (they do not depend on per-bar series). Vars + # referencing series / ta.* results / history subscripts / strategy + # state are NOT here, so a TA length fed by them is still rejected + # by the constructor guard. + self._stable_runtime_vars: set[str] = set() + # ``_var_names`` (var/varip persistent-state members) is needed by the + # stability classifier during _collect_known_vars, so pre-seed it from + # the analyzer's var_members before that pass runs; the canonical + # assignment below preserves the existing initialization order. + self._var_names: set[str] = set() + for _vn, _, _ in ctx.var_members: + self._var_names.add(_vn) self._collect_known_vars() # Track var names - self._var_names: set[str] = set() + self._var_names = set() for name, _, _ in ctx.var_members: self._var_names.add(name) # Every name bound ANYWHERE in the program (top-level, nested in @@ -396,6 +430,87 @@ def __init__(self, ctx: AnalyzerContext) -> None: for fi in ctx.func_infos: self._func_names.add(fi.name) self._func_info_map[fi.name] = fi + # Dead-code user functions: those that contain TA call sites but are + # never called anywhere in the script (no call site registered them, + # so func_call_site_counts reports 0). Their OWN TA ctor args still + # carry bare parameter names (e.g. ``dirmov_short(len) => ta.rma(ta.tr, len)``) + # that can never be resolved to a concrete length, and since the + # function never runs its TA buffers would be dead weight anyway. + # Track the dead TA site indices and dead function names so emission + # can skip both — the ctor guard no longer hard-fails on the bare + # param and no dangling member/function body is emitted. A function + # with zero call sites but NO TA state is NOT dead-by-this-rule (it + # may still be emitted; harmless if truly unreferenced). + # + # IMPORTANT: dead-ness of a TA site is decided by the site's + # ``owner_func`` (set by the analyzer), NOT by which function's + # ``func_ta_ranges`` slice the site happens to fall in. A function's + # slice can include clones of ANOTHER (live) function's sites that + # were minted while visiting THIS function's body (a nested call to + # a live callee registers the callee's cs{N} clones in the caller's + # TA-range slice). Keying dead-ness off the slice would drop those + # borrowed clones' declarations, leaving the owning callee's emitted + # clone body referencing undeclared members. Regression: + # quantbyboji-nq-hma-midday (``_ta_change_*_cs1`` / ``_ta_rma_*_cs1`` + # minted inside dead ``adx_short``'s body but owned by live ``dirmov``). + self._dead_func_names: set[str] = set() + self._dead_ta_indices: set[int] = set() + for _fn in (ctx.func_ta_ranges or {}): + if (ctx.func_call_site_counts or {}).get(_fn, 0) > 0: + continue + # Only treat plain user functions (not UDT methods) as skippable + # dead code; methods are dispatched through the UDT and their + # call-site tracking is handled separately. + fi = self._func_info_map.get(_fn) + if fi is not None and getattr(fi, "is_udt_method", False): + continue + self._dead_func_names.add(_fn) + # Mark TA sites dead ONLY when their owner is a dead function. A + # site with ``owner_func=None`` (top-level) or whose owner is a + # live function survives -- even if it sits inside a dead + # function's TA-range slice (it's a borrowed clone). + for _i, _site in enumerate(ctx.ta_call_sites): + _owner = getattr(_site, "owner_func", None) + if _owner is not None and _owner in self._dead_func_names: + self._dead_ta_indices.add(_i) + # Build per-call-site fixnan remap + site map (mirrors TA remap above). + # Dead fixnan sites (owner is a dead function) are skipped at decl time. + clone_fn_names = getattr(ctx, "func_cs_fixnan_clone_names", {}) + for _i, _fsite in enumerate(ctx.fixnan_sites): + _fowner = getattr(_fsite, "owner_func", None) + if _fowner is not None and _fowner in self._dead_func_names: + self._dead_fixnan_indices.add(_i) + # cs0 fixnan remap is identity (originals). Build originals per func. + func_fixnan_originals: dict[str, list[str]] = {} + for _fname, _idxs in (ctx.func_fixnan_indices or {}).items(): + origs = [ctx.fixnan_sites[i].member_name for i in _idxs + if i not in self._dead_fixnan_indices] + if origs: + func_fixnan_originals[_fname] = origs + self._func_cs_fixnan_remap[(_fname, 0)] = {m: m for m in origs} + self._func_fixnan_members.update(origs) + # cs > 0 remap uses the ``{orig}_cs{cs_idx}`` formula (or the + # analyzer's disambiguated name from func_cs_fixnan_clone_names). + for _fname, _origs in func_fixnan_originals.items(): + _total_cs = ctx.func_call_site_counts.get(_fname, 1) + for _cs_idx in range(1, _total_cs): + _overrides = clone_fn_names.get((_fname, _cs_idx), {}) + _remap = {} + for _orig in _origs: + _remap[_orig] = _overrides.get(_orig, f"{_orig}_cs{_cs_idx}") + self._func_cs_fixnan_remap[(_fname, _cs_idx)] = _remap + self._func_fixnan_members.update(_remap.values()) + # Site map: node id -> original site (cs0). Skip dead sites and + # function-local originals (the active remap dispatches variants). + for _i, _fsite in enumerate(ctx.fixnan_sites): + if _i in self._dead_fixnan_indices: + continue + if _fsite.node is None: + continue + if _fsite.member_name not in self._func_fixnan_members: + self._fixnan_site_map[id(_fsite.node)] = _fsite + elif id(_fsite.node) not in self._fixnan_site_map: + self._fixnan_site_map[id(_fsite.node)] = _fsite # Track strategy series vars (e.g., strategy.closedtrades[1]) self._strategy_series_vars: set[str] = set() # Track global-scope non-var declarations (emitted as class members) @@ -658,7 +773,8 @@ def _build_func_instances(self) -> None: ctx = self.ctx stateful = (set(ctx.func_ta_ranges.keys()) | set(ctx.func_var_members.keys()) - | set(ctx.func_series_vars.keys())) + | set(ctx.func_series_vars.keys()) + | set(ctx.func_fixnan_indices.keys())) if not stateful: return @@ -674,6 +790,9 @@ def ta_originals(fname: str) -> list[str]: def var_originals(fname: str) -> list[str]: return [self._safe_name(n) for n, _, _ in ctx.func_var_members.get(fname, [])] + def fixnan_originals(fname: str) -> list[str]: + return list(self._func_cs_fixnan_remap.get((fname, 0), {}).keys()) + def natural_name(fname: str, cs_idx: int) -> str: return f"{self._func_safe_name(fname)}_cs{cs_idx}" @@ -694,6 +813,7 @@ def natural_name(fname: str, cs_idx: int) -> str: "name": natural_name(fname, k), "ta_remap": self._func_cs_ta_remap.get((fname, k), {}), "var_remap": self._func_cs_var_remap.get((fname, k), {}), + "fixnan_remap": self._func_cs_fixnan_remap.get((fname, k), {}), }) else: worklist.append({ @@ -701,6 +821,7 @@ def natural_name(fname: str, cs_idx: int) -> str: "name": self._func_safe_name(fname), "ta_remap": {}, "var_remap": {}, + "fixnan_remap": {}, }) while worklist: @@ -712,6 +833,7 @@ def natural_name(fname: str, cs_idx: int) -> str: if not body: continue active_ta = inst["ta_remap"] + active_fixnan = inst.get("fixnan_remap", {}) for callnode in self._iter_func_calls(body): cs_info = ctx.func_call_cs_map.get(id(callnode)) if cs_info is None: @@ -724,12 +846,18 @@ def natural_name(fname: str, cs_idx: int) -> str: for m in ta_originals(g_name): mid = natural_ta.get(m, m) composed_ta[m] = active_ta.get(mid, mid) - if composed_ta == natural_ta: + natural_fixnan = self._func_cs_fixnan_remap.get((g_name, j), {}) + composed_fixnan = {} + for m in fixnan_originals(g_name): + mid = natural_fixnan.get(m, m) + composed_fixnan[m] = active_fixnan.get(mid, mid) + if composed_ta == natural_ta and composed_fixnan == natural_fixnan: # Path resolves to the callee's own cs{j} clone — reuse it. self._instance_dispatch[(inst["name"], id(callnode))] = \ natural_name(g_name, j) continue - key = (g_name, frozenset(composed_ta.items())) + key = (g_name, frozenset(composed_ta.items()), + frozenset(composed_fixnan.items())) ginst = interned.get(key) if ginst is None: fresh_counter += 1 @@ -739,11 +867,28 @@ def natural_name(fname: str, cs_idx: int) -> str: fresh_member = f"{v}__ni{fresh_counter}" fvar_remap[v] = fresh_member self._fresh_var_members.append((v, fresh_member)) + # Fresh fixnan members: each path gets its OWN previous- + # value member so two call paths never share fixnan state. + ffixnan_remap: dict[str, str] = {} + for orig_fn_member in fixnan_originals(g_name): + fresh_fn_member = f"{orig_fn_member}__ni{fresh_counter}" + ffixnan_remap[orig_fn_member] = fresh_fn_member + # Find the original FixnanCallSite to carry its type. + orig_fn_site = None + for _fs in ctx.fixnan_sites: + if _fs.member_name == orig_fn_member: + orig_fn_site = _fs + break + if orig_fn_site is not None: + self._fresh_fixnan_members.append( + (orig_fn_site, fresh_fn_member) + ) ginst = { "fname": g_name, "name": inst_name, "ta_remap": composed_ta, "var_remap": fvar_remap, + "fixnan_remap": ffixnan_remap, } interned[key] = ginst self._fresh_instances.append(ginst) @@ -1027,6 +1172,134 @@ def _collect_known_vars(self) -> None: for stmt in self.ctx.ast.body: if isinstance(stmt, VarDecl) and stmt.name not in reassigned: self._collect_known_var(stmt) + # A second pass handles the stable-reassigned-scalar pattern: a + # class-scope scalar initialized from a stable expr and reassigned + # ONLY inside top-level if/elif chains whose conditions and assigned + # values are themselves stable (inputs / timeframe.* / math.*). Such + # a var is a bar-invariant scalar and may feed a TA ctor length with + # a runtime reset that reproduces the conditional logic. Series- + # dependent reassignments are left untracked (rejected by the guard). + self._collect_reassigned_stable_scalars(reassigned) + + def _collect_reassigned_stable_scalars(self, reassigned: set[str]) -> None: + """Track class-scope scalars that are reassigned but only along stable + if/elif paths (see ``test_stable_reassigned_class_scope_length``). + + For each top-level ``v = `` whose name is reassigned, build the + final value as a nested ternary by folding subsequent top-level + IfStmts / direct Assignments. If every condition and every assigned + RHS is stable (and renderable), record the ternary in + ``_derived_input_expr`` and add ``v`` to ``_stable_runtime_vars`` so + the TA ctor reset path can expand it. Anything non-stable (a ta.* + result, a bar field, a series var) leaves the var untracked, so the + ctor guard still rejects it loudly. + """ + from ..ast_nodes import IfStmt, Assignment + body = self.ctx.ast.body or [] + # Pre-resolve each reassigned var's initial VarDecl. + inits: dict[str, object] = {} + for stmt in body: + if (isinstance(stmt, VarDecl) and stmt.name in reassigned + and not stmt.is_var and not stmt.is_varip): + # Only consider vars whose initial value is itself stable; + # an unstable init cannot become a stable scalar via later + # reassignment. + if stmt.value is not None and self._expr_is_stable(stmt.value): + inits[stmt.name] = stmt.value + if not inits: + return + + def _value_after(stmts, fallback: str | None) -> str | None: + """Fold a statement list into the final value expression for the + target var, given ``fallback`` as the value on entry. Returns None + if any condition / assignment is non-stable or unrenderable.""" + current = fallback + for s in stmts or []: + if isinstance(s, Assignment) and isinstance(s.target, Identifier): + if s.target.name != target_name: + continue + if s.op != ":=": + return None # compound assignment — not a stable fold + if not self._expr_is_stable(s.value): + return None + rhs = self._arith_expr_to_str(s.value) + if rhs is None: + return None + current = rhs + elif isinstance(s, IfStmt): + # Only model IfStmts that actually reassign the target var; + # an unrelated IfStmt (e.g. entry/exit logic with a series + # condition) must NOT abort the fold — the var simply keeps + # its current value through it. + if not _reassigns(s, target_name): + continue + if not self._expr_is_stable(s.condition): + return None + cond = self._arith_expr_to_str(s.condition) + if cond is None: + return None + then_val = _value_after(s.body, current) + if then_val is None: + return None + else_val = _value_after(s.else_body, current) + if else_val is None: + return None + current = f"({cond} ? {then_val} : {else_val})" + # Other statement shapes (for/while/switch/var decls of + # other vars) are ignored for this var's value fold; they + # do not reassign ``target_name`` in a way we model. + return current + + def _reassigns(node, name: str) -> bool: + """True if any ``:=`` assignment to ``name`` occurs within node.""" + from ..ast_nodes import IfStmt as _If, Assignment as _Asg + if isinstance(node, _Asg) and isinstance(node.target, Identifier): + return node.target.name == name + if isinstance(node, _If): + if any(_reassigns(c, name) for c in (node.body or [])): + return True + if any(_reassigns(c, name) for c in (node.else_body or [])): + return True + return False + for attr in ("body", "else_body", "cases", "default_body"): + sub = getattr(node, attr, None) + if isinstance(sub, list): + if any(_reassigns(c, name) for c in sub): + return True + return False + + for target_name, init_node in inits.items(): + init_str = self._arith_expr_to_str(init_node) + if init_str is None: + continue + final = _value_after(body, init_str) + if final is None: + continue + # Sanity: the fold must actually differ from the bare init, + # otherwise there were no stable reassignments and the var is + # already covered (or rejected) by the main pass. + if final == init_str: + continue + # Fold to a compile-time literal when possible (so the ctor-init + # list can use it directly); otherwise record the raw expression + # for the runtime reset path to expand. + folded = self._resolve_known(final) + if self._is_compile_time_value(folded): + try: + num = float(folded) + self._known_vars[target_name] = ( + int(num) if num == int(num) else num + ) + except ValueError: + pass + self._derived_input_expr[target_name] = final + self._stable_runtime_vars.add(target_name) + # Mark input-backed iff the expression references an input so the + # override-aware get_input_*() reads are emitted on the reset path. + import re as _re + toks = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", final)) + if any(t in self._input_backed_vars for t in toks): + self._input_backed_vars.add(target_name) def _find_reassigned_vars(self) -> set[str]: """Scan AST to find all variable names that are targets of := or compound assignment.""" @@ -1053,6 +1326,114 @@ def walk(node): walk(stmt) return reassigned + # ``math.*`` members that are pure functions over their (stable) args, or + # stable constants. Anything outside this set (e.g. ``math.random``) is + # treated as non-stable. Used by ``_expr_is_stable``. + _MATH_STABLE_MEMBERS: frozenset[str] = frozenset({ + "pi", "e", "phi", "rphi", + "abs", "max", "min", "round", "floor", "ceil", + "sqrt", "log", "log10", "exp", "pow", + "sin", "cos", "tan", "asin", "acos", "atan", "sign", + "sum", "avg", "to_precision", "round_to_mintick", + }) + + # ``timeframe.*`` members that are constant for the lifetime of a run — + # they reflect the script's resolution, not a per-bar value. + _TF_STABLE_MEMBERS: frozenset[str] = frozenset({ + "period", "main_period", "multiplier", + "isintraday", "isminutes", "isdaily", "isweekly", + "ismonthly", "isdwm", "isseconds", "in_seconds", "isticks", + }) + + def _expr_is_stable(self, node) -> bool: + """True iff ``node``'s value is a bar-invariant scalar. + + A stable expression depends only on: literals, ``input.*`` values, + previously-tracked stable runtime vars, known compile-time consts, + ``timeframe.*`` members (constant per run), ``syminfo.*`` (constant + per instrument), and ``math.*`` functions/consts over stable + sub-expressions, combined with arithmetic / comparison / logical + ops, ternaries, and the ``int/float/bool/string`` casts. + + Returns False (i.e. "series") for any node that references a per-bar + value: bar fields (close/open/...), series vars, history subscripts, + ``ta.*`` results, strategy.* state, or any unrecognised construct. + The conservative False keeps the TA-ctor guard intact for genuinely + dynamic lengths. + """ + if node is None: + return False + if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral)): + return True + if isinstance(node, NaLiteral): + return True + if isinstance(node, Identifier): + name = node.name + if name in self._known_vars: + return True + if name in self._stable_runtime_vars: + return True + if name in self._input_backed_vars: + return True + if name in self.ctx.series_vars: + return False + if name in self._var_names: + # var/varip persistent state — mutable across bars. + return False + if name in BAR_FIELDS or name in BAR_BUILTINS: + return False + # Unrecognised bare identifier: be conservative so we never + # silently allow an undeclared / dynamic length through. + return False + if isinstance(node, MemberAccess): + if isinstance(node.object, Identifier): + ns = node.object.name + if ns == "timeframe": + return node.member in self._TF_STABLE_MEMBERS + if ns == "math": + return node.member in self._MATH_STABLE_MEMBERS + if ns == "syminfo": + # syminfo.* (mintick, pointvalue, tickerid, ...) is + # constant for the run — safe as a stable scalar. + return True + # bar.* / request.* / any other member access reads per-bar or + # dynamic state. + return False + if isinstance(node, Subscript): + # History read (``close[1]``) or indexed access — per-bar. + return False + if isinstance(node, Ternary): + return (self._expr_is_stable(node.condition) + and self._expr_is_stable(node.true_val) + and self._expr_is_stable(node.false_val)) + if isinstance(node, BinOp): + return self._expr_is_stable(node.left) and self._expr_is_stable(node.right) + if isinstance(node, UnaryOp): + return self._expr_is_stable(node.operand) + if isinstance(node, FuncCall): + func_name, namespace = self._resolve_callee(node.callee) + if namespace == "ta": + return False + if namespace == "math": + if func_name not in self._MATH_STABLE_MEMBERS: + return False + return all(self._expr_is_stable(a) for a in node.args) + if namespace == "timeframe": + # ``timeframe.in_seconds()`` (and any other function-form + # timeframe member) is a stable per-run scalar — it reflects + # the script's resolution, not a per-bar value. + if func_name not in self._TF_STABLE_MEMBERS: + return False + return all(self._expr_is_stable(a) for a in node.args) + if namespace == "input": + return True + if namespace is None and func_name in ("int", "float", "bool", "string"): + return all(self._expr_is_stable(a) for a in node.args) + # Any other call (user functions, str.*, array.*, ...) — series + # by default; the conservative answer keeps the guard honest. + return False + return False + def _arith_expr_to_str(self, node) -> str | None: """Render a numeric arithmetic-over-identifiers expression to a string whose token spelling matches ``_resolve_known`` / ``_runtime_ctor_arg_for_reset`` @@ -1079,6 +1460,13 @@ def _arith_expr_to_str(self, node) -> str | None: if o is None: return None return f"{node.op}{o}" + if isinstance(node, Ternary): + c = self._arith_expr_to_str(node.condition) + t = self._arith_expr_to_str(node.true_val) + f = self._arith_expr_to_str(node.false_val) + if c is None or t is None or f is None: + return None + return f"{c} ? {t} : {f}" if isinstance(node, FuncCall): callee = self._arith_expr_to_str(node.callee) if callee is None: @@ -1144,36 +1532,56 @@ def _collect_known_var(self, node: VarDecl) -> None: if stored: self._input_backed_vars.add(node.name) self._input_var_to_call[node.name] = node.value - # Class-scope arithmetic over known/input-backed vars - # (``wilderLen = rsiLen * 2 - 1``, ``n = math.round(len / 2)``). + # Class-scope arithmetic / ternaries / casts over known, input-backed, + # timeframe.*, or math.* operands + # (``wilderLen = rsiLen * 2 - 1``, ``fastPeriod = isM5 ? ... : ...``, + # ``filterLen = math.max(1, int(math.round(2 / a)))``). # Without this branch the derived name is untracked, the TA ctor arg # never folds, and the runtime-reset path silently degenerates to a - # period of 1. We (a) fold to a literal for the ctor-init list and - # (b) record the raw expression so the reset path can re-expand any - # input-backed operand to its get_input_*() runtime read. - elif isinstance(node.value, (BinOp, UnaryOp, FuncCall)): + # period of 1. We (a) fold to a literal for the ctor-init list when + # possible and (b) record the raw expression so the reset path can + # re-expand any input-backed operand to its get_input_*() runtime read + # and render timeframe.* / math.* fragments to valid C++. + # + # The ``_expr_is_stable`` gate is what separates a faithful stable + # scalar (inputs + constants + timeframe + math) from a series-derived + # value: a length that depends on a ta.* result, a history subscript, + # or a bar field stays untracked and is therefore rejected by the TA + # ctor guard — preserving the guardrail for genuine dynamic lengths. + elif isinstance(node.value, (BinOp, UnaryOp, FuncCall, Ternary)): expr_str = self._arith_expr_to_str(node.value) - if expr_str is not None: + if expr_str is not None and self._expr_is_stable(node.value): import re as _re tokens = set(_re.findall(r"[A-Za-z_][A-Za-z_0-9]*", expr_str)) - refs_known = any(t in self._known_vars for t in tokens) refs_input = any(t in self._input_backed_vars for t in tokens) refs_derived = any(t in self._derived_input_expr for t in tokens) - if refs_known or refs_input or refs_derived: - folded = self._resolve_known(expr_str) - if self._is_compile_time_value(folded): - try: - num = float(folded) - self._known_vars[node.name] = ( - int(num) if num == int(num) else num - ) - except ValueError: - pass - if refs_input or refs_derived: - # Track as input-backed so use-sites are not inlined and - # the runtime-reset path emits the override-aware expr. - self._derived_input_expr[node.name] = expr_str - self._input_backed_vars.add(node.name) + # The stability classifier already proved this expression is a + # bar-invariant scalar (inputs / constants / timeframe.* / + # math.* / syminfo.* only). Track it unconditionally so later + # stable exprs (and the TA reset path) can reference / expand + # it — e.g. ``pi = math.asin(1) * 2`` feeds ``beta`` feeds + # ``alpha`` feeds a function-local ``filterLen``. + folded = self._resolve_known(expr_str) + if self._is_compile_time_value(folded): + try: + num = float(folded) + self._known_vars[node.name] = ( + int(num) if num == int(num) else num + ) + except ValueError: + pass + # Record the raw expression so the runtime-reset path can + # re-expand operands. Always record for stable derived exprs + # (even pure-math / pure-timeframe ones with no input) so the + # reset can render them. + self._derived_input_expr[node.name] = expr_str + self._stable_runtime_vars.add(node.name) + # Mark input-backed so use-sites are not inlined and the + # override-aware get_input_*() reads are emitted on the reset + # path. Pure-math / pure-timeframe exprs (no input) stay out + # of this set, which is fine — they have no override to honor. + if refs_input or refs_derived: + self._input_backed_vars.add(node.name) # ------------------------------------------------------------------ # Public entry point @@ -1411,7 +1819,9 @@ def generate(self) -> str: ) # 3. TA members - for site in self.ctx.ta_call_sites: + for _ta_idx, site in enumerate(self.ctx.ta_call_sites): + if _ta_idx in self._dead_ta_indices: + continue lines.append(f" {site.class_name} {site.member_name};") if self._ta_site_uses_precalc(site): vtype = self._ta_return_type(site) @@ -1521,7 +1931,9 @@ def generate(self) -> str: lines.append(f" Series<{cpp_type}> {safe}{_mbb};") # 7. Fixnan members - for site in self.ctx.fixnan_sites: + for _fi_idx, site in enumerate(self.ctx.fixnan_sites): + if _fi_idx in self._dead_fixnan_indices: + continue cpp_type = PINE_TYPE_TO_CPP.get(site.pine_type, "double") lines.append(f" {cpp_type} {site.member_name} = na<{cpp_type}>();") @@ -1594,6 +2006,16 @@ def generate(self) -> str: emitted_clones.add(fresh_safe) self._emit_cloned_var_decl(orig_safe, fresh_safe, _mbb, lines) + # 8c3. Fresh fixnan members for context-sensitive helper instances. + # Each fresh instance gets its OWN previous-value member so two + # call paths never share fixnan state (mirrors 8c2 for vars). + for orig_site, fresh_safe in self._fresh_fixnan_members: + if fresh_safe in emitted_clones: + continue + emitted_clones.add(fresh_safe) + cpp_type = PINE_TYPE_TO_CPP.get(orig_site.pine_type, "double") + lines.append(f" {cpp_type} {fresh_safe} = na<{cpp_type}>();") + # 8d. Drawing-objects-as-data arenas (gated on _uses_drawing so # non-drawing strategies emit byte-identical C++). Each arena is a # per-strategy member -> reset-per-run is automatic. Caps come from @@ -1648,6 +2070,12 @@ def generate(self) -> str: # 10. User-defined functions (with per-call-site variants for functions # containing TA calls OR series variables that need isolation) for fi in self.ctx.func_infos: + # Dead-code user functions (defined but never called, with TA + # state whose ctor args can't be sized) are skipped entirely — + # their bodies reference TA members we no longer emit, and the + # functions never run anyway. + if fi.name in self._dead_func_names: + continue total_cs = self.ctx.func_call_site_counts.get(fi.name, 0) has_ta = fi.name in self.ctx.func_ta_ranges has_series = fi.name in self.ctx.func_series_vars or fi.name in self.ctx.func_var_members @@ -1846,77 +2274,279 @@ def _is_omitted_udt_field(self, node) -> bool: # _infer_type / _infer_tuple_types live on TypeInferer — see codegen/types.py. # _is_compile_time_value lives on TaSiteHelper — see codegen/ta.py. + # Pine ``timeframe.`` -> C++ runtime expression. Mirrors the + # mapping in ``visit_expr._visit_member_access`` so a stable timeframe + # fragment embedded in a TA ctor reset renders to the same C++ the + # expression visitor would emit for a direct ``timeframe.*`` read. + _TIMEFRAME_MEMBER_CPP: dict[str, str] = { + "period": "script_tf_", + "main_period": "main_period()", + "multiplier": "tf_multiplier(script_tf_)", + "isintraday": "tf_is_intraday(script_tf_)", + "isminutes": "(tf_is_intraday(script_tf_) && !tf_is_seconds(script_tf_))", + "isdaily": "tf_is_daily(script_tf_)", + "isweekly": "tf_is_weekly(script_tf_)", + "ismonthly": "tf_is_monthly(script_tf_)", + "isdwm": "(tf_is_daily(script_tf_) || tf_is_weekly(script_tf_) || tf_is_monthly(script_tf_))", + "isseconds": "tf_is_seconds(script_tf_)", + "in_seconds": "tf_to_seconds(script_tf_)", + "isticks": "false", + } + + # Pine ``math.`` -> C++ form. Function members map to ``std::*``; + # constants map to their engine-side macro / literal. + _MATH_MEMBER_CPP: dict[str, str] = { + "pi": "M_PI", "e": "M_E", "phi": "1.618033988749895", + "rphi": "0.6180339887498949", + "abs": "std::abs", "max": "std::max", "min": "std::min", + "round": "std::round", "floor": "std::floor", "ceil": "std::ceil", + "sqrt": "std::sqrt", "log": "std::log", "log10": "std::log10", + "exp": "std::exp", "pow": "std::pow", + "sin": "std::sin", "cos": "std::cos", "tan": "std::tan", + "asin": "std::asin", "acos": "std::acos", "atan": "std::atan", + "sign": "(double)([] (double _v) { return (_v>0) - (_v<0); })", + } + + # Pine logical operators (word form) -> C++ operator, used when rendering + # a stable runtime expression. Matched with word boundaries. + _PINE_LOGICAL_OPS: dict[str, str] = {"and": "&&", "or": "||", "not": "!"} + + def _render_inline_input_calls(self, expr_str: str) -> tuple[str, bool]: + """Render inline ``input(...)`` / ``input.(...)`` calls in a TA + ctor-arg expression string to override-aware ``get_input_*()`` reads. + + A bare input expression passed straight as a length argument + (``adx(input(15), input(15))``) reaches the reset path as the raw call + spelling ``input(15)`` because the analyzer's param-substitution has no + intermediate variable to record in ``_input_backed_vars``. This helper + finds each such call (balanced parens, ``input`` optionally followed by + ``.``), re-parses it into a FuncCall, and renders it via the same + ``_render_input_value`` used for ordinary input var reads. + + Returns ``(rewritten_str, found_any)``. When no inline input call is + present, the string is returned unchanged with ``found_any=False``. + """ + import re + # Locate ``input`` (as a word, not a substring of get_input_int etc.) + # optionally followed by ``.``, then a ``(`` opening a balanced + # argument list. + out = expr_str + found = False + idx = 0 + while idx < len(out): + m = re.search(r"\binput\b", out[idx:]) + if m is None: + break + start = idx + m.start() + # Reject a match that is part of a longer identifier + # (e.g. ``get_input_int``) — the \b guard above already handles + # alphanumerics, but be defensive. + if start > 0 and (out[start - 1].isalnum() or out[start - 1] == "_"): + idx = start + len("input") + continue + j = start + len("input") + # Optional ``.`` for the typed form ``input.int(...)``. + member = None + if j < len(out) and out[j] == ".": + k = j + 1 + nm_start = k + while k < len(out) and (out[k].isalnum() or out[k] == "_"): + k += 1 + if k > nm_start: + member = out[nm_start:k] + j = k + # Must be followed by ``(`` to be a call. + if j >= len(out) or out[j] != "(": + idx = j + continue + # Walk the balanced parens to extract the call substring. + depth = 0 + k = j + while k < len(out): + ch = out[k] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + k += 1 + break + k += 1 + if depth != 0: + # Unbalanced — bail on this match. + idx = j + 1 + continue + call_src = out[start:k] + try: + from ..lexer import Lexer + from ..parser import Parser + tokens = Lexer(call_src).tokenize() + node = Parser(tokens, source=call_src)._parse_expression() + if not self._is_input_call(node): + idx = k + continue + func_name_i, namespace_i = self._resolve_callee(node.callee) + # Inline inputs have no enclosing var; reuse the default + # value as a synthetic title key so distinct defaults get + # distinct input controls (and identical defaults collapse, + # which is correct since they resolve to the same value). + default_node = self._get_input_default(node) + synth_title = self._visit_expr(default_node) if default_node is not None else "" + title = self._get_input_title(node, var_name=None) + if not title: + title = synth_title + rendered = self._render_input_value(node, func_name_i, namespace_i, title) + except Exception: + idx = k + continue + out = out[:start] + rendered + out[k:] + found = True + idx = start + len(rendered) + return out, found + def _runtime_ctor_arg_for_reset(self, arg_str: str) -> str | None: - """Convert a TA ctor-arg string into its runtime C++ expression when - the source expression references an input-backed variable. Returns the - runtime expression (e.g. ``get_input_int("MACD Fast", 12)``) when the - ctor arg depends on an input value; returns None for pure literals or - expressions that do not contain any input-backed identifier, so the - caller can decide to skip emitting a reset for that site. + """Convert a TA ctor-arg string into its runtime C++ expression. + + Returns the runtime expression (e.g. + ``get_input_int("MACD Fast", 12)`` or a ternary / math expression + over such reads and ``timeframe.*`` members) when the ctor arg + depends on a stable runtime scalar — an input-backed variable, a + ``timeframe.*`` member, or arithmetic / ternaries / casts over + those. Returns None for pure literals or expressions that contain + any unrecognised (potentially series) identifier, so the caller + (the TA ctor guard) rejects them loudly instead of silently + emitting period 1. """ import re ident_re = re.compile(r"[A-Za-z_][A-Za-z_0-9]*") - # Expand class-scope derived vars (``wilderLen`` -> ``(rsiLen * 2 - 1)``) - # to their raw RHS so the input-backed leaves become get_input_*() reads - # below. Recursive (bounded) to handle chains of derived vars; guards - # against cycles. + # Expand class-scope derived vars (``wilderLen`` -> ``(rsiLen * 2 - 1)``, + # ``fastPeriod`` -> ``(isM5 ? ... : ...)``) to their raw RHS so input + # leaves become get_input_*() reads below. Recursive (bounded) to + # handle chains of derived vars; guards against cycles. def _expand_derived(s: str, seen: frozenset = frozenset(), depth: int = 0) -> str: if depth > 32: return s - def _rep(m: re.Match) -> str: - nm = m.group(0) + def _rep(p: re.Match) -> str: + nm = p.group(0) if nm in self._derived_input_expr and nm not in seen: inner = self._derived_input_expr[nm] return "(" + _expand_derived(inner, seen | {nm}, depth + 1) + ")" return nm return ident_re.sub(_rep, s) - arg_str = _expand_derived(arg_str) + expanded = _expand_derived(arg_str) + + tokens = set(ident_re.findall(expanded)) + + # Gate: every identifier token must be renderable. If any token is an + # unrecognised bare identifier (not an input, not a known const, not + # a structural keyword / namespace prefix, not a stable tracked var + # that we already expanded), we conservatively refuse — that identifier + # would otherwise leak through as an undeclared C++ symbol, or worse, + # a series var that should have been rejected by the ctor guard. + structural = (set(self._PINE_LOGICAL_OPS) + | {"timeframe", "math", "syminfo", + "int", "float", "bool", "string", + "true", "false", "na"}) + member_tokens = (set(self._TIMEFRAME_MEMBER_CPP) + | set(self._MATH_MEMBER_CPP)) + renderable = (self._input_backed_vars + | self._stable_runtime_vars + | structural + | member_tokens) + leftover = tokens - renderable + # Known compile-time consts that survived expansion (e.g. ``pi`` was + # NOT tracked as a Python value but its name token is a stable var + # already; pure numeric names are in _known_vars and covered above). + leftover = {t for t in leftover if t not in self._known_vars} + # Inline ``input(...)`` / ``input.(...)`` calls (a bare input + # expression passed straight as a length arg, e.g. + # ``adx(input(15), input(15))``) are re-parsed and rendered below, + # after the gate. ``input`` is the only token they contribute, so + # allow it through the gate here. + leftover.discard("input") + if leftover: + return None - tokens = ident_re.findall(arg_str) - input_tokens = [t for t in tokens if t in self._input_backed_vars] - if not input_tokens: + # Must depend on at least one runtime component (input-backed var, a + # timeframe reference, or an inline input() call); otherwise it's a + # pure compile-time expr and no reset is needed (the ctor-init literal + # is correct). + has_input = any(t in self._input_backed_vars for t in tokens) + has_timeframe = "timeframe" in tokens + has_inline_input = "input" in tokens + if not (has_input or has_timeframe or has_inline_input): return None - # Pine math.* → C++ std::* (must run before identifier substitution so - # we don't treat `math.round` etc. as a bare identifier). We wrap the - # whole expression in (int) below because TA ctors want integer lengths. - expr = arg_str - math_map = { - "math.round": "std::round", - "math.sqrt": "std::sqrt", - "math.ceil": "std::ceil", - "math.floor": "std::floor", - "math.abs": "std::abs", - "math.max": "std::max", - "math.min": "std::min", - "math.log": "std::log", - "math.exp": "std::exp", - "math.pow": "std::pow", - } - for pine_fn, cpp_fn in math_map.items(): - expr = expr.replace(pine_fn, cpp_fn) - - def _sub(match: re.Match) -> str: - name = match.group(0) - if name not in self._input_backed_vars: - return name - call_node = self._input_var_to_call.get(name) - if call_node is None: - return name - func_name_i, namespace_i = self._resolve_callee(call_node.callee) - title = self._get_input_title(call_node, var_name=name) - return self._render_input_value(call_node, func_name_i, namespace_i, title) + # Render inline input() calls to override-aware get_input_*() reads + # now that the gate has accepted the expression. Done after the gate + # so the rendered getter tokens (get_input_int, ...) do not have to + # be added to ``renderable``. + if has_inline_input: + expanded, _ = self._render_inline_input_calls(expanded) + + expr = expanded + + # 1) timeframe. -> C++ (before ident substitution so the + # member names don't get caught by the identifier pass). Use a + # targeted regex so e.g. ``isminutes`` is not confused with + # ``ismonthly``. + def _tf_rep(p: re.Match) -> str: + mem = p.group(1) + return self._TIMEFRAME_MEMBER_CPP.get(mem, p.group(0)) + # ``timeframe.in_seconds()`` is a function-form member in Pine (the + # only one in the table); its C++ form ``tf_to_seconds(script_tf_)`` + # is already a complete call, so consume the Pine ``()`` to avoid a + # double-call ``tf_to_seconds(script_tf_)()``. Property-form members + # (``timeframe.isdaily``) never carry ``()`` so the optional group is + # a no-op for them. + expr = re.sub(r"\btimeframe\.(\w+)(?:\(\))?", _tf_rep, expr) + + # 2) math. -> C++ (constants + std::* functions). + def _math_rep(p: re.Match) -> str: + mem = p.group(1) + return self._MATH_MEMBER_CPP.get(mem, p.group(0)) + expr = re.sub(r"\bmath\.(\w+)", _math_rep, expr) + + # 3) Pine word-logical operators -> C++ operators (after timeframe/math + # substitution so we don't rewrite inside their C++ expansions). + for pine_op, cpp_op in self._PINE_LOGICAL_OPS.items(): + expr = re.sub(rf"\b{pine_op}\b", cpp_op, expr) + + # 4) Substitute input-backed vars with override-aware get_input_*() + # reads, and inline known compile-time consts (non-input) as literals. + def _sub(p: re.Match) -> str: + name = p.group(0) + if name in self._input_backed_vars: + call_node = self._input_var_to_call.get(name) + if call_node is None: + return name + func_name_i, namespace_i = self._resolve_callee(call_node.callee) + title = self._get_input_title(call_node, var_name=name) + return self._render_input_value(call_node, func_name_i, namespace_i, title) + if name in self._known_vars and name not in self._input_backed_vars: + val = self._known_vars[name] + if isinstance(val, bool): + return "true" if val else "false" + if isinstance(val, (int, float)): + return str(val) + return f'std::string("{self._cpp_string_escape(val)}")' + return name rewritten = ident_re.sub(_sub, expr) - # Pine auto-converts floats to ints for TA lengths; C++ does not, so - # wrap the whole expression in an explicit int cast when any math.* - # function appears (they return doubles). - if any(m in arg_str for m in math_map): + + # 5) Pine auto-converts floats to ints for TA lengths; C++ does not. + # If any math.* function appears (returns double) OR a timeframe.* + # boolean is part of a ternary whose branches are doubles, wrap the + # whole expression in an explicit int cast so the TA ctor gets an + # integer length. + had_math = "std::" in rewritten or bool(re.search(r"\btimeframe\b", expanded)) + if had_math: return f"(int)({rewritten})" return rewritten + def _collect_ta_runtime_resets(self) -> list[str]: """Collect reassignment statements for every TA object whose ctor args depend on an input-backed variable. Returned strings are raw C++ @@ -1928,7 +2558,9 @@ def _collect_ta_runtime_resets(self) -> list[str]: resets: list[str] = [] # Main-context TA objects - for site in self.ctx.ta_call_sites: + for _ta_idx, site in enumerate(self.ctx.ta_call_sites): + if _ta_idx in self._dead_ta_indices: + continue if not site.ctor_args: continue runtime_args: list[str] = [] diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index b34f620..9f6c3ee 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -212,7 +212,11 @@ def _typed_na_init(self, cpp_val: str, name: str, ptype) -> str: def _emit_constructor(self, lines: list[str]) -> None: init_parts: list[str] = [] # TA members with ctor args - for site in self.ctx.ta_call_sites: + for ta_idx, site in enumerate(self.ctx.ta_call_sites): + # Skip dead-code function TA sites entirely — their buffers never + # run and their ctor args (bare param names) can never be sized. + if ta_idx in self._dead_ta_indices: + continue if site.ctor_args: # If a ctor arg is neither a compile-time literal nor expandable # to an input-backed runtime expression, the old code silently @@ -857,6 +861,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No func_name = instance["name"] self._active_ta_remap = instance["ta_remap"] self._active_var_remap = instance["var_remap"] + self._active_fixnan_remap = instance.get("fixnan_remap", {}) self._in_ta_func_variant = True self._active_call_site_idx = None self._current_instance_name = instance["name"] @@ -867,12 +872,14 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._active_ta_remap = remap var_remap = self._func_cs_var_remap.get((fi.name, call_site_idx), {}) self._active_var_remap = var_remap + self._active_fixnan_remap = self._func_cs_fixnan_remap.get((fi.name, call_site_idx), {}) self._in_ta_func_variant = True self._active_call_site_idx = call_site_idx self._current_instance_name = f"{self._func_safe_name(fi.name)}_cs{call_site_idx}" else: self._active_ta_remap = {} self._active_var_remap = {} + self._active_fixnan_remap = {} self._in_ta_func_variant = False self._active_call_site_idx = None self._current_instance_name = None @@ -973,6 +980,7 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No self._udt_ptr_alias_locals = prev_ptr_alias self._active_ta_remap = {} self._active_var_remap = {} + self._active_fixnan_remap = {} self._in_ta_func_variant = False self._active_call_site_idx = None self._current_instance_name = None @@ -1040,7 +1048,11 @@ class member but its initializer was dropped, leaving the member ``na`` lines.append(" }") def _emit_precalculate_and_run(self, lines: list[str]) -> None: - has_static_ta = any(self._ta_site_uses_precalc(site) for site in self.ctx.ta_call_sites) + has_static_ta = any( + self._ta_site_uses_precalc(site) + for _ti, site in enumerate(self.ctx.ta_call_sites) + if _ti not in self._dead_ta_indices + ) if not has_static_ta: return @@ -1062,13 +1074,17 @@ def _emit_precalculate_and_run(self, lines: list[str]) -> None: lines.append("") # Resize precalculated vectors - for site in self.ctx.ta_call_sites: + for _ti, site in enumerate(self.ctx.ta_call_sites): + if _ti in self._dead_ta_indices: + continue if self._ta_site_uses_precalc(site): lines.append(f" _precalc_{site.member_name}.resize(n);") # Reset indicators to clean slate lines.append("") - for site in self.ctx.ta_call_sites: + for _ti, site in enumerate(self.ctx.ta_call_sites): + if _ti in self._dead_ta_indices: + continue if self._ta_site_uses_precalc(site): resolved = [self._resolve_known(a) for a in site.ctor_args] safe_resolved = [] @@ -1164,7 +1180,9 @@ def _emit_precalculate_and_run(self, lines: list[str]) -> None: # Set _precalc_loop_active = True self._precalc_loop_active = True try: - for site in self.ctx.ta_call_sites: + for _ti, site in enumerate(self.ctx.ta_call_sites): + if _ti in self._dead_ta_indices: + continue if self._ta_site_uses_precalc(site): compute_args = self._ta_compute_args_for_site(site) compute_args_bars = compute_args.replace("current_bar_.", "bars[i].") @@ -1176,7 +1194,9 @@ def _emit_precalculate_and_run(self, lines: list[str]) -> None: # Reset indicators and series for the real backtest run lines.append("") - for site in self.ctx.ta_call_sites: + for _ti, site in enumerate(self.ctx.ta_call_sites): + if _ti in self._dead_ta_indices: + continue if self._ta_site_uses_precalc(site): resolved = [self._resolve_known(a) for a in site.ctor_args] safe_resolved = [] diff --git a/pineforge_codegen/codegen/input.py b/pineforge_codegen/codegen/input.py index 58858cd..b2643ef 100644 --- a/pineforge_codegen/codegen/input.py +++ b/pineforge_codegen/codegen/input.py @@ -206,6 +206,23 @@ def _render_input_value(self, node: FuncCall, func_name: str | None, default = self._get_input_default(node) default_cpp = self._visit_expr(default) if default is not None else "0" getter = self._input_type_to_getter(func_name, namespace) + # The generic ``input(...)`` overload is typed by its defval in Pine + # v6 (an int default yields an int input). The static getter table + # cannot see the default, so infer the getter from the default's + # literal type here. This matters for TA lengths: ``input(15)`` must + # route to ``get_input_int`` so the RMA/EMA ctor receives an int. + if func_name == "input" and namespace is None: + if isinstance(default, BoolLiteral): + getter = "get_input_bool" + elif isinstance(default, NumberLiteral): + if isinstance(default.value, bool): + getter = "get_input_bool" + elif isinstance(default.value, int): + getter = "get_input_int" + else: + getter = "get_input_double" + elif isinstance(default, StringLiteral): + getter = "get_input_string" default_cpp = self._coerce_string_input_default(getter, default_cpp) return f'{getter}("{title}", {default_cpp})' diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index ab20cdf..89b276d 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -1242,8 +1242,34 @@ def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> N def _visit_fixnan(self, node: FuncCall) -> str: """Emit fixnan with persistent state member.""" - self._fixnan_counter += 1 - member = f"_prev_fixnan_{self._fixnan_counter}" + # Variant-aware lookup keyed off the analyzer-tracked site: + # * function-owned site -> dispatch through the active per-call-site + # remap so each emitted variant (cs0/cs1/__ni{N}) references its + # OWN previous-value member. + # * top-level site (owner_func is None) -> use ``site.member_name`` + # directly. The declarations come from ``ctx.fixnan_sites`` keyed + # by these member names, so referencing anything else (e.g. the + # legacy monotonic counter) would either dangle a declaration or + # silently alias another site's state. In particular, when a + # function-owned fixnan is analyzed BEFORE a top-level one, the + # counter would restart at 1 and collide with the function's + # ``_prev_fixnan_1`` -- corrupting both. Using the site's own + # member name keeps declaration and reference in lockstep. + # * unmapped site (node not in the site map, e.g. a fixnan reached + # only through a path the analyzer didn't register) -> fall back + # to the legacy monotonic counter so emission still produces a + # referenceable member. + site = self._fixnan_site_map.get(id(node)) + if site is not None: + if site.member_name in self._func_fixnan_members: + member = self._active_fixnan_remap.get( + site.member_name, site.member_name + ) + else: + member = site.member_name + else: + self._fixnan_counter += 1 + member = f"_prev_fixnan_{self._fixnan_counter}" x = self._visit_expr(node.args[0]) return f"(is_na({x}) ? {member} : ({member} = {x}))" diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 6d1a957..1c7659a 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -214,6 +214,33 @@ def _parse_single_statement(self): if cur.type in (TokenType.VAR, TokenType.VARIP): return self._parse_var_keyword_decl() + # Type-qualifier prefix on a top-level declaration. Pine v6 allows + # ``const`` / ``simple`` / ``series`` to qualify a typed variable + # declaration (``const int DEFAULT = 1``). The qualifier carries no + # C++-side meaning (the engine infers mutability from ``var``/ + # ``varip``), so consume it and parse the underlying declaration. + # Without this the qualifier is parsed as a bare identifier and the + # subsequent typed decl leaks the stray ``ExprStmt(const)`` that + # produces "Unknown variable 'const'" at codegen. + if (cur.type == TokenType.IDENT + and cur.value in ("const", "series", "simple")): + nxt = self._peek() + # ``const int NAME = ...`` / ``const float[] x = ...`` + typed_after_qual = ( + nxt.type in TYPE_KEYWORDS + or (nxt.type == TokenType.IDENT and self._is_ident_typed_var_decl(offset=1)) + ) + # ``const NAME = ...`` (qualifier on an untyped decl — rare but + # legal; ``const`` here is just dropped). + bare_after_qual = ( + nxt.type == TokenType.IDENT + and self._peek(2).type == TokenType.EQUALS + and self._peek(3).type != TokenType.EQUALS + ) + if typed_after_qual or bare_after_qual: + self._advance() # consume the qualifier prefix + return self._parse_single_statement() + # Type-annotated declaration: float x = ..., int x = ... if cur.type in TYPE_KEYWORDS and self._peek().type == TokenType.IDENT: # Check that the IDENT is followed by = (not == ) to confirm declaration @@ -295,7 +322,7 @@ def _parse_expr_or_assign_stmt(self): return self._set_loc(ExprStmt(expr=expr), start_tok) - def _is_ident_typed_var_decl(self) -> bool: + def _is_ident_typed_var_decl(self, offset: int = 0) -> bool: """Look ahead for ``IDENT [<...>] IDENT '='`` (UDT-typed declaration). Triggered by ``Sample s = expr`` / ``array w = expr`` / @@ -305,15 +332,21 @@ def _is_ident_typed_var_decl(self) -> bool: ``true``, ``false``), and function-definition shapes (``IDENT '(' ... ')' '=>'``). + ``offset`` shifts the starting position (used to look ahead past a + consumed type-qualifier prefix like ``const``). + Probe: data/validation/udt-method-probe-19-array-of-udt-method. """ - cur = self._current() + base = self.pos + offset + if base >= len(self.tokens): + return False + cur = self.tokens[base] if cur.type != TokenType.IDENT: return False if cur.value in ("enum", "type", "strategy", "indicator", "na", "true", "false"): return False # Skip past optional generic args after the type ident: IDENT [< ... >] - i = self.pos + 1 + i = base + 1 if i < len(self.tokens) and self.tokens[i].type == TokenType.LT: depth = 1 i += 1 diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index b050d49..9c81cef 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -446,6 +446,68 @@ def test_fixnan_translation(): assert "is_na" in cpp +def test_fixnan_top_level_and_function_owned_do_not_alias(): + """Regression: a top-level ``fixnan`` must reference its OWN declared + member, not the member owned by a function fixnan analyzed earlier. + + Bug history: ``_visit_fixnan`` fell back to a monotonic counter for any + mapped site that was NOT function-owned. Since the analyzer assigns + member names (``_prev_fixnan_``) in source order, a function-owned + fixnan analyzed first claims ``_prev_fixnan_1``; the subsequent + top-level fixnan's declaration is ``_prev_fixnan_2`` but the codegen + reference restarted the counter at 1, silently aliasing the function's + state. The fix uses ``site.member_name`` directly for top-level sites + and reserves the counter fallback only for genuinely unmapped sites. + """ + src = """//@version=6 +strategy("fixnan-no-alias") +f(x) => + fixnan(x) +a = fixnan(close) +b = f(high) +plot(a) +plot(b) +""" + cpp = transpile(src) + + # Exactly two fixnan members, declared by the analyzer in source order: + # _prev_fixnan_1 -> f's body (analyzed first, function-owned) + # _prev_fixnan_2 -> top-level a = fixnan(close) + import re + declared = re.findall(r"double\s+(_prev_fixnan_\d+)\s*=", cpp) + assert declared == ["_prev_fixnan_1", "_prev_fixnan_2"], ( + f"expected two fixnan members in source order; got {declared}" + ) + + # The top-level ``a = fixnan(close)`` reference MUST be _prev_fixnan_2, + # NOT _prev_fixnan_1 (which belongs to f). The pre-fix bug aliased them. + top_level_refs = re.findall( + r"_prev_fixnan_(\d+)\s*=\s*current_bar_\.close", cpp + ) + assert top_level_refs, "top-level fixnan(close) reference not found" + assert top_level_refs == ["2"], ( + f"top-level fixnan(close) must reference _prev_fixnan_2 (its own " + f"declared member); got references to {top_level_refs}. This is " + f"the aliasing regression -- the top-level site is silently " + f"sharing the function f's fixnan state." + ) + + # Sanity: the function-owned fixnan (inside f's body) references + # _prev_fixnan_1, distinct from the top-level _prev_fixnan_2. + func_refs = re.findall(r"_prev_fixnan_(\d+)\s*=\s*x", cpp) + assert func_refs == ["1"], ( + f"f's body fixnan must reference _prev_fixnan_1; got {func_refs}" + ) + + # Stronger: every referenced _prev_fixnan_N must have a declaration. + referenced = set(re.findall(r"(_prev_fixnan_\d+)", cpp)) + declared_set = set(declared) + assert referenced <= declared_set, ( + f"referenced fixnan members without declarations: " + f"{referenced - declared_set}" + ) + + def test_cpp_name_safety(): src = '//@version=6\nstrategy("T")\nexp = true\n' cpp = _generate(src) diff --git a/tests/test_codegen_ta_derived_length.py b/tests/test_codegen_ta_derived_length.py index 01cc848..6035a64 100644 --- a/tests/test_codegen_ta_derived_length.py +++ b/tests/test_codegen_ta_derived_length.py @@ -129,6 +129,57 @@ def test_nested_user_func_param_length(): assert "ta::SMA(1)" not in cpp +# --------------------------------------------------------------------------- +# 2c. Length threaded through TWO levels of nested user functions whose +# call site passes an INLINE input() call (no intermediate var name). +# Mirrors the quantbyboji DMI/ADX pattern: +# dirmov(len) => ta.rma(ta.tr, len) +# adx(dilen, adxlen) => dirmov(dilen); ta.rma(x, adxlen) +# sig = adx(input(15), input(15)) +# The ctor args must resolve to input-backed reset expressions +# (get_input_int(..., 15)), never to the bare parameter name ``len`` +# or the unresolved call spelling ``input(15)``. +# --------------------------------------------------------------------------- + +def test_nested_user_func_inline_input_length(): + src = """//@version=6 +strategy("derived-nested-inline-input") +dirmov(len) => + truerange = ta.rma(ta.tr, len) + plus = ta.rma(close, len) / truerange +adx(dilen, adxlen) => + x = dirmov(dilen) + ta.rma(x, adxlen) +sig = adx(input(15), input(15)) +plot(sig) +""" + cpp = transpile(src) + rma_members = re.findall(r"(_ta_rma_\d+)\(", cpp) + assert rma_members, "no RMA member emitted" + # Every RMA ctor that sizes a buffer must be backed by a runtime reset + # that reads the input value (override-aware); no bare ``len`` and no + # unresolved ``input(15)`` spelling may survive in the reset. + saw_input_backed = False + for m in rma_members: + reset = _reset_line(cpp, m) + if not reset: + continue + if "get_input_int(" in reset and "15" in reset: + saw_input_backed = True + assert "input(15)" not in reset, ( + f"reset for {m} still carries the unresolved input() spelling: {reset}" + ) + assert " ta::RMA(len)" not in reset and "= len;" not in reset, ( + f"reset for {m} references the bare param name: {reset}" + ) + assert saw_input_backed, ( + "no RMA runtime reset references the input value; ctor args did not " + f"resolve to input-backed expressions. members: {rma_members}" + ) + assert "ta::RMA(len)" not in cpp + assert "ta::RMA(input(15))" not in cpp + + # --------------------------------------------------------------------------- # 3. Legitimate input that genuinely defaults to 1 stays period 1 # --------------------------------------------------------------------------- @@ -181,3 +232,300 @@ def test_derived_length_transpile_is_deterministic(): plot(x) """ assert transpile(src) == transpile(src) + + +# --------------------------------------------------------------------------- +# 4. Stable timeframe-ternary length +# fastPeriod = isM5 ? math.max(1, int(maPeriodFastInput / 5)) +# : maPeriodFastInput +# where isM5 = timeframe.isminutes and timeframe.multiplier == 5 +# All operands are stable per-run scalars (an input + timeframe.*) so the +# ctor guard must accept it and the reset must render to valid C++ that +# references no undeclared locals. +# --------------------------------------------------------------------------- + +def test_stable_timeframe_ternary_length(): + src = """//@version=6 +strategy("stable-timeframe-ternary") +maPeriodFastInput = input.int(60, "Fast SMA Period") +isM5 = timeframe.isminutes and timeframe.multiplier == 5 +fastPeriod = isM5 ? math.max(1, int(maPeriodFastInput / 5)) : maPeriodFastInput +fastMA = ta.sma(close, fastPeriod) +plot(fastMA) +""" + cpp = transpile(src) + members = re.findall(r"(_ta_sma_\d+)\(", cpp) + assert members, "no SMA member emitted" + member = members[0] + # ctor-init list uses a safe placeholder (1); the runtime reset + # overwrites it on the first bar. + assert _ctor_period(cpp, member) == "1" + reset = _reset_line(cpp, member) + assert reset, "no runtime reset emitted for stable-timeframe length" + # Override-aware: the input read appears in the reset. + assert 'get_input_int("Fast SMA Period", 60)' in reset + # The timeframe condition renders to the engine's tf_* helpers, NOT to + # the bare Pine ``timeframe.isminutes`` / a dangling ``isM5`` local. + assert "tf_is_intraday(script_tf_)" in reset + assert "tf_multiplier(script_tf_)" in reset + assert "timeframe.isminutes" not in reset + assert " isM5 " not in reset + # Pine ``and`` must be lowered to C++ ``&&``. + assert " && " in reset + # Ternary preserved as a C++ ternary. + assert " ? " in reset and " : " in reset + # math.max / int(...) cast lowered to valid C++. + assert "std::max" in reset + + +# --------------------------------------------------------------------------- +# 5. Function-local math-derived length over input-derived parameters +# filterLen = math.max(1, int(math.round(2 / a))) inside AIFilter(data,a,p) +# where alpha/beta/pi are class-scope stable scalars derived from inputs. +# --------------------------------------------------------------------------- + +def test_function_local_math_derived_length(): + src = """//@version=6 +strategy("func-local-math-length") +period = input.int(144, "Period") +poles = input.int(4, "Poles") +pi = math.asin(1) * 2 +beta = (1 - math.cos(2 * pi / period)) / (math.pow(1.414, 2 / poles) - 1) +alpha = -beta + math.sqrt(beta * beta + 2 * beta) +AIFilter(data, a, p) => + filterLen = math.max(1, int(math.round(2 / a))) + ta.ema(data, filterLen) +out = AIFilter(close, alpha, poles) +plot(out) +""" + cpp = transpile(src) + members = re.findall(r"(_ta_ema_\d+)\(", cpp) + assert members, "no EMA member emitted" + member = members[0] + # ctor placeholder; reset overwrites it. + assert _ctor_period(cpp, member) == "1" + reset = _reset_line(cpp, member) + assert reset, "no runtime reset emitted for function-local math length" + # The reset references BOTH inputs (period, poles) override-aware. + assert 'get_input_int("Period", 144)' in reset + assert 'get_input_int("Poles", 4)' in reset + # math.* and the int() cast lower to valid C++; no Pine-namespace leak. + assert "std::asin" in reset + assert "std::cos" in reset + assert "std::sqrt" in reset + assert "std::pow" in reset + assert "std::round" in reset + assert "std::max" in reset + assert "math.asin" not in reset + assert "math.cos" not in reset + # No dangling function-local identifier (``alpha`` / ``filterLen``) + # should survive — they must be fully expanded to input reads + math. + assert "alpha" not in reset + assert "filterLen" not in reset + + +# --------------------------------------------------------------------------- +# Guardrail (negative): a ternary length whose condition depends on a +# series (ta.*) result stays rejected — it is NOT a stable scalar. +# --------------------------------------------------------------------------- + +def test_series_dependent_ternary_length_rejected(): + src = """//@version=6 +strategy("series-dep-ternary") +normalSwingLookback = input.int(10, "Normal") +highVolSwingLookback = input.int(20, "High Vol") +highVolThreshold = input.float(2.0, "Threshold") +volatilityRatio = ta.rma(close, 14) / ta.atr(14) +activeSwingLookback = volatilityRatio >= highVolThreshold ? highVolSwingLookback : normalSwingLookback +x = ta.lowest(low, activeSwingLookback) +plot(x) +""" + with pytest.raises(CompileError) as ei: + transpile(src) + assert "Unsupported TA constructor length" in str(ei.value) + + +# --------------------------------------------------------------------------- +# Guardrail (negative): a reassigned series-dependent length stays rejected. +# --------------------------------------------------------------------------- + +def test_series_reassigned_length_rejected(): + src = """//@version=6 +strategy("series-reassigned") +n = ta.barssince(close > open) +v = ta.ema(close, n) +plot(v) +""" + with pytest.raises(CompileError): + transpile(src) + + +# --------------------------------------------------------------------------- +# 6. Stable REASSIGNED class-scope scalar length. +# effectiveSwingSize = swingSize (initial: input) +# if autoSwingSize (cond: input bool) +# if timeframe.isdaily (cond: stable) +# effectiveSwingSize := math.max(swingSize, 3) +# else if timeframe.in_seconds() >= 3600 (cond: stable) +# effectiveSwingSize := math.max(swingSize, 2) +# pivHi = ta.pivothigh(high, effectiveSwingSize, effectiveSwingSize) +# +# Stable per run because every condition and assignment uses only +# inputs / timeframe.* / math.*. The ctor guard must accept it and the +# reset must render to valid C++ that references the input reads. +# --------------------------------------------------------------------------- + +def test_stable_reassigned_class_scope_length(): + src = """//@version=6 +strategy("stable-reassigned-scalar") +swingSize = input.int(1, "Pivot Lookback") +autoSwingSize = input.bool(true, "Auto") +effectiveSwingSize = swingSize +if autoSwingSize + if timeframe.isdaily + effectiveSwingSize := math.max(swingSize, 3) + else if timeframe.in_seconds() >= 3600 + effectiveSwingSize := math.max(swingSize, 2) +pivHi = ta.pivothigh(high, effectiveSwingSize, effectiveSwingSize) +plot(pivHi) +""" + cpp = transpile(src) + members = re.findall(r"(_ta_pivothigh_\d+)\(", cpp) + assert members, "no PivotHigh member emitted" + member = members[0] + # ctor placeholder; reset overwrites it on the first bar. + assert _ctor_period(cpp, member) in ("1", "1, 1"), ( + f"expected ctor placeholder, got {_ctor_period(cpp, member)}" + ) + reset = _reset_line(cpp, member) + assert reset, "no runtime reset emitted for reassigned scalar length" + # Override-aware: both inputs appear in the reset. + assert 'get_input_int("Pivot Lookback", 1)' in reset + assert 'get_input_bool("Auto", true)' in reset + # timeframe.* renders to the engine's tf_* helpers, not bare Pine. + assert "tf_is_daily(script_tf_)" in reset + assert "tf_to_seconds(script_tf_)" in reset + assert "timeframe.isdaily" not in reset + assert "timeframe.in_seconds" not in reset + # math.max lowered to std::max. + assert "std::max" in reset + # No dangling local name survives — it must be fully expanded. + assert "effectiveSwingSize" not in reset + assert "autoSwingSize" not in reset + + +# --------------------------------------------------------------------------- +# Guardrail (negative): the wellmanapex shape — a reassigned scalar whose +# value depends on a SERIES (ta.rma result) — stays rejected even though +# the structure (initial VarDecl + reassigned in a ternary) superficially +# resembles the stable case above. +# --------------------------------------------------------------------------- + +def test_series_reassigned_ternary_length_rejected(): + src = """//@version=6 +strategy("series-reassigned-ternary") +normalSwingLookback = input.int(10, "Normal") +highVolSwingLookback = input.int(20, "High Vol") +highVolThreshold = input.float(2.0, "Threshold") +volatilityRatio = ta.rma(close, 14) / ta.atr(14) +activeSwingLookback = volatilityRatio >= highVolThreshold ? highVolSwingLookback : normalSwingLookback +x = ta.lowest(low, activeSwingLookback) +plot(x) +""" + with pytest.raises(CompileError) as ei: + transpile(src) + assert "Unsupported TA constructor length" in str(ei.value) + + +# --------------------------------------------------------------------------- +# 7. Cloned no-ctor TA sites (ta.change) referenced by a cloned function +# variant MUST be declared + initialized even when the clone is minted +# while visiting a DEAD user function's body. +# +# Shape (mirrors quantbyboji-nq-hma-midday DMI): +# dirmov(len) => uses ta.change (no-ctor) + ta.rma(len) (ctor) +# adx(dilen, adxlen) => calls dirmov(dilen); ta.rma(adxlen) +# adx_dead(dilen, adxlen) => calls dirmov(dilen); ta.rma(adxlen) +# sig = adx(input(15), input(15)) # adx_dead is NEVER called +# +# The analyzer registers dirmov's cs0 clones while visiting adx's body, +# and dirmov's cs1 clones while visiting adx_dead's body. adx_dead's +# TA range therefore INCLUDES dirmov's cs1 clones; when adx_dead is +# classified dead (zero call sites), the dead-code pass must NOT mark +# those clones dead -- they belong to dirmov (a LIVE callee), and +# dirmov_cs1's emitted body references them by name +# (``_ta_change__cs1``, ``_ta_rma__cs1``). Previously the pass +# keyed dead-ness off the dead function's TA-range slice and dropped +# the clone declarations, producing +# ``error: use of undeclared identifier '_ta_change__cs1'``. +# --------------------------------------------------------------------------- + +def test_cloned_no_ctor_ta_sites_declared_through_dead_caller(): + src = """//@version=6 +strategy("cs1-clone-through-dead-fn") +diLen = input.int(15, "DI Length") +adxLen = input.int(15, "ADX Length") +dirmov(len) => + up = ta.change(high) + down = -ta.change(low) + plusDM = na(up) ? na : up > down and up > 0 ? up : 0 + minusDM = na(down) ? na : down > up and down > 0 ? down : 0 + truerange = ta.rma(ta.tr, len) + plus = fixnan(100 * ta.rma(plusDM, len) / truerange) + minus = fixnan(100 * ta.rma(minusDM, len) / truerange) + [plus, minus] +adx() => + [plus, minus] = dirmov(diLen) + sum = plus + minus + adx_val = 100 * ta.rma(math.abs(plus - minus) / (sum == 0 ? 1 : sum), adxLen) + adx_val +adx_dead() => + [plus, minus] = dirmov(diLen) + sum = plus + minus + adx_val = 100 * ta.rma(math.abs(plus - minus) / (sum == 0 ? 1 : sum), adxLen) + adx_val +sig = adx() +plot(sig) +""" + cpp = transpile(src) + + # Every _ta_* identifier referenced in the emitted C++ MUST have a + # corresponding ``ta:: ;`` declaration. This catches the + # exact regression: ``_ta_change__cs1`` / ``_ta_rma__cs1`` + # referenced by the cloned ``dirmov_cs1`` body but dropped from the + # member list by the dead-code pass. + declared = set(re.findall(r"ta::\w+\s+(\w+)\s*;", cpp)) + referenced = set() + for m in re.finditer(r"(_ta_\w+)\s*(?:\.|=)", cpp): + referenced.add(m.group(1)) + referenced |= set(re.findall(r"(_ta_\w+)\s*\.", cpp)) + # Exclude internal bookkeeping flags (``_ta_initialized_``) -- those are + # declared as ``bool _ta_initialized_ = false;`` separately, not as TA + # members; they are not part of this regression. + referenced.discard("_ta_initialized_") + + missing = referenced - declared + assert not missing, ( + f"TA members referenced in emitted C++ but never declared: " + f"{sorted(missing)}" + ) + + # The no-ctor ``ta.change`` clones specifically must be present + # (this is the exact class that regressed -- it has no ctor arg so the + # old codegen never even tried to recover it via the reset path). + change_clones = re.findall(r"ta::Change\s+(_ta_change_\w+)\s*;", cpp) + assert any("_cs1" in n for n in change_clones), ( + f"expected at least one _ta_change_*_cs1 declaration; got {change_clones}" + ) + + # And at least one ``dirmov_cs1`` body must be emitted (i.e. we did not + # silently drop the clone -- it compiles against the now-declared members). + assert re.search(r"\bdirmov_cs1\s*\(", cpp), ( + "dirmov_cs1 clone body not emitted" + ) + + # Sanity: the genuinely-dead adx_dead function is still skipped. + assert not re.search(r"\badx_dead\b\s*\(", cpp), ( + "dead adx_dead body should not be emitted" + ) + diff --git a/tests/test_compile_smoke.py b/tests/test_compile_smoke.py index 528eb69..0990bcc 100644 --- a/tests/test_compile_smoke.py +++ b/tests/test_compile_smoke.py @@ -625,3 +625,64 @@ def test_matrix_int_concat_compiles(): var other = matrix.new(2, 2, 1) m.concat(other, false) """) + + +# --------------------------------------------------------------------------- +# Regression: cloned no-ctor TA sites (ta.change) + ctor TA sites (ta.rma) +# referenced by a cloned function variant MUST be declared when the clone +# is minted inside a DEAD caller's body. Mirrors the quantbyboji DMI shape: +# dirmov(len) => ta.change(high); ta.rma(ta.tr, len) // no-ctor + ctor mix +# adx() => dirmov(diLen); ta.rma(...) // live caller +# adx_dead() => dirmov(diLen); ta.rma(...) // dead caller (never called) +# The dead caller's body visit mints dirmov's cs1 clones; the dead-code pass +# must NOT drop them (they belong to live dirmov). Previously the emitted +# dirmov_cs1 body referenced undeclared _ta_change_*_cs1 / _ta_rma_*_cs1. +# --------------------------------------------------------------------------- + +def test_regression_cloned_no_ctor_ta_through_dead_caller_compiles(): + skip_if_no_compile_env() + _check("cs1_clone_through_dead_caller", """ +diLen = input.int(15, "DI Length") +adxLen = input.int(15, "ADX Length") +dirmov(len) => + up = ta.change(high) + down = -ta.change(low) + plusDM = na(up) ? na : up > down and up > 0 ? up : 0 + minusDM = na(down) ? na : down > up and down > 0 ? down : 0 + truerange = ta.rma(ta.tr, len) + plus = fixnan(100 * ta.rma(plusDM, len) / truerange) + minus = fixnan(100 * ta.rma(minusDM, len) / truerange) + [plus, minus] +adx() => + [plus, minus] = dirmov(diLen) + sum = plus + minus + adx_val = 100 * ta.rma(math.abs(plus - minus) / (sum == 0 ? 1 : sum), adxLen) + adx_val +adx_dead() => + [plus, minus] = dirmov(diLen) + sum = plus + minus + adx_val = 100 * ta.rma(math.abs(plus - minus) / (sum == 0 ? 1 : sum), adxLen) + adx_val +sig = adx() +plot(sig) +""") + + +# --------------------------------------------------------------------------- +# Regression: a top-level ``fixnan`` must not alias a function-owned +# ``fixnan`` member when the function fixnan is analyzed first. Mirrors +# test_fixnan_top_level_and_function_owned_do_not_alias in +# test_codegen_new.py, but here we assert the emitted C++ parses against +# the engine headers (catches the aliasing at the compile level too). +# --------------------------------------------------------------------------- + +def test_regression_fixnan_top_level_not_aliased_with_function_compiles(): + skip_if_no_compile_env() + _check("fixnan_no_alias", """ +f(x) => + fixnan(x) +a = fixnan(close) +b = f(high) +plot(a) +plot(b) +""")