diff --git a/pineforge_codegen/__init__.py b/pineforge_codegen/__init__.py index b706fafd..7ffbdccb 100644 --- a/pineforge_codegen/__init__.py +++ b/pineforge_codegen/__init__.py @@ -8,7 +8,9 @@ from .codegen import CodeGen from .cpp_allowlist import REASONS, check_generated_cpp from .diagnostic_codes import diagnostics_catalog, render_diagnostic -from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation +from .errors import ( + CodegenInvariantError, CompileError, Diagnostic, Level, Phase, SourceLocation, +) from .external_requests import lower_no_data_requests from .builtin_keywords import bind_builtin_keywords from .finite_ta_length import expand_finite_choice_extrema_lengths @@ -105,8 +107,22 @@ def _generate(pine_source: str, check_support: bool, filename: str, # consumes them directly from the context to emit the on_bar tail # ``if (trace_enabled_) { trace(...); ... }`` block. ctx.pf_trace_pragmas = pragmas - gen = CodeGen(ctx, budget=budget) - cpp = gen.generate() + try: + gen = CodeGen(ctx, budget=budget) + cpp = gen.generate() + except CodegenInvariantError as error: + node_kind = error.node_kind + location = getattr(error.node, "loc", None) + if location is None: + raise RuntimeError( + f"Internal error: AST node '{node_kind}' has no source " + "location for its missing value lowering." + ) from error + raise CompileError([Diagnostic( + level=Level.ERROR, phase=Phase.CODEGEN, location=location, + message=f"Internal error: no value lowering for AST node '{node_kind}'.", + hint="Report this translator error to PineForge.", + )]) from error budget.check(phase=Phase.CODEGEN) apart = gen.block_locals_needing_names - renamed if apart: diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index a47f22d4..893a2bb0 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -7959,7 +7959,8 @@ def _visit_MemberAccess(self, node: MemberAccess) -> PineType: # timeframe.* if ns == "timeframe": - return PineType.STRING + return sigs.TIMEFRAME_VARIABLES.get( + f"timeframe.{node.member}", PineType.STRING) # barstate.* (ishistory, isrealtime, islast, isfirst, etc.) if ns == "barstate": diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 20e6caf0..c317de0e 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -549,6 +549,7 @@ def func_var_storage(owner: str, raw_name: str) -> str: # string local read with history) live in their own map; the member # is declared when a payload's helper holds one. self._security_string_series_declared: bool = False + self._security_persistent_helper_series: set[str] = set() self._security_string_series: set[str] = set() # Color var state shares the numeric double map, but reads retain the # ordinary packed-ARGB int64_t type (including its na sentinel). @@ -1268,6 +1269,8 @@ def func_var_storage(owner: str, raw_name: str) -> str: "symbol_node": item.get("symbol_node"), "ignore_invalid_node": item.get("ignore_invalid_node"), }) + self._security_selection_local_members = self._security_selection_local_member_names() + self._global_member_vars.difference_update(self._security_selection_local_members) self._register_global_aggregate_member_types() self._uses_map = self._detect_map_usage() self._uses_matrix = self._detect_matrix_usage() @@ -5759,6 +5762,8 @@ def _generate_cpp(self) -> str: # (so user-defined functions can reference them) seen_global = set() for name, ptype in self.ctx.global_var_decls: + if name in self._security_selection_local_members: + continue exact_scalar_override = ( name in self._qualified_func_var_raw_names and name in self._ordinary_global_binding_names diff --git a/pineforge_codegen/codegen/helpers.py b/pineforge_codegen/codegen/helpers.py index 33589915..01b2c160 100644 --- a/pineforge_codegen/codegen/helpers.py +++ b/pineforge_codegen/codegen/helpers.py @@ -434,6 +434,7 @@ def cpp_code_only(cpp: str) -> str: """.split()) _CPP_TEMPORARY_PATTERN = re.compile( r"_v\d+|_secval_\d+(?:_v\d+)?|_tuple_result_\d+|_tuple_unused_\d+|__switch_val_\d+" + r"|__selection_value_\d+" r"|_for_(?:start|end|end_eval)_\d+|_pf_(?:str|array|round)_a\d+" r"|_pf_every_bar_ta_\d+|_pf_shared_\d+_\d+|__pf_array\w*|__pf_raw_\w+" r"|_pf_collection_hist_\w+" diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 91c3fe51..1b4b51c9 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -88,6 +88,7 @@ from ..security_contexts import GLOBAL_ANNOTATION, UNREACHED_ANNOTATION from ..symbols import PineType, method_receiver_type_name from .tables import ( + ARRAY_JOURNAL_DELTA_METHODS, ARRAY_JOURNAL_FULL_METHODS, MATRIX_VOID_METHODS, BAR_BUILTINS, DRAWING_TYPE_TO_CPP, MATH_FUNC_MAP, PINE_TYPE_TO_CPP, SECURITY_BAR_FIELDS, SECURITY_BAR_FIELD_EXPRS, SECURITY_BAR_FIELD_TYPES, TA_TUPLE_FIELDS, TIMEZONE_LAMBDA_BODIES, @@ -306,7 +307,43 @@ def _refuse_invalid_security_tf(self, value: str, node) -> None: def _check_security_tf_arms(self, node, resolving: set[str]) -> None: """Refuse an invalid timeframe string a registration-time timeframe expression can select: the expression itself, a ternary or switch - arm, read through the constants and globals it names.""" + arm, read through the constants and globals it names. + + If/switch arms known to be empty need a measured chart-timeframe + value before admission. Scalar ternaries keep their existing route. + """ + def check_empty_selection(value, selection_arm=False) -> None: + if isinstance(value, StringLiteral): + if selection_arm and value.value == "": + from ..diagnostic_codes import render_diagnostic + + message, hint = render_diagnostic("PF-E3105", {}) + self._codegen_error(value, message, hint=hint) + elif isinstance(value, Ternary): + check_empty_selection(value.true_val, selection_arm) + check_empty_selection(value.false_val, selection_arm) + elif (isinstance(value, FuncCall) and isinstance(value.callee, Identifier) + and value.callee.name == "string" and len(value.args) == 1 + and not value.kwargs): + # A typed arm-local string let retains its identity cast. + check_empty_selection(value.args[0], selection_arm) + elif isinstance(value, (IfStmt, SwitchStmt)): + arms = ((value.body, value.else_body) if isinstance(value, IfStmt) + else [body for _, body in value.cases] + [value.default_body]) + for body in arms: + if body: + terminal = body[-1] + check_empty_selection( + terminal.expr if isinstance(terminal, ExprStmt) else terminal, + True, + ) + + # Both scalar and block selections reach registration here. Existing + # substitution follows constants/globals and resolves block-local lets, + # retaining the empty literal's location. Input defaults are not values. + substituted = self._substitute_tf_input_reads(node, resolving) + check_empty_selection(substituted) + def check(value) -> None: if isinstance(value, StringLiteral): self._refuse_invalid_security_tf(value.value, value) @@ -319,8 +356,11 @@ def check(value) -> None: if len(body) == 1 and isinstance(body[0], ExprStmt): check(self._substitute_tf_input_reads(body[0].expr, resolving)) + if self._security_selection_has_block_arms(node): + check(substituted) + return check(node if isinstance(node, SwitchStmt) - else self._substitute_tf_input_reads(node, resolving)) + else substituted) def _security_tf_literal(self, tf: str) -> str: """A timeframe string as the C++ string literal registration and the @@ -356,28 +396,121 @@ def _security_tf_runtime_expr(self, node, resolving: set[str] | None = None) -> """ if node is None: return None + if self._security_selection_has_block_arms(node): + substituted = self._substitute_tf_input_reads(node, resolving or set()) + if isinstance(substituted, IfStmt): + return self._security_tf_if_runtime_expr(substituted) + + def scalar_arm(body): + expr = self._security_selection_arm_expr(body, substituted) + return [ExprStmt(expr=expr, loc=expr.loc)] + + substituted = replace( + substituted, + cases=[(case, scalar_arm(body)) for case, body in substituted.cases], + default_body=scalar_arm(substituted.default_body) + if substituted.default_body else [], + ) + return self._security_tf_switch_runtime_expr(substituted, resolving or set()) if isinstance(node, SwitchStmt): return self._security_tf_switch_runtime_expr(node, resolving or set()) substituted = self._substitute_tf_input_reads(node, resolving or set()) return self._visit_expr(substituted) + def _security_tf_if_runtime_expr(self, node: IfStmt) -> str: + """Render normalized setup arms without entering statement emission. + + Only selections with statement prefixes enter here. Scalar selections + retain the ordinary expression visitor and its counter allocation. + """ + cpp_type, initial = self._selection_expression_slot(node) + target = f"__selection_value_{self._switch_counter}" + self._switch_counter += 1 + lines = [f"([&]() -> {cpp_type} {{", + f" {cpp_type} {target} = {initial};"] + + def arm(body, indent): + last = body[-1] if body else None + if isinstance(last, (IfStmt, SwitchStmt)): + emit(last, indent) + return + expr = self._security_selection_arm_expr(body, node) + value = (self._na_value_for_type(cpp_type) if isinstance(expr, NaLiteral) + else self._security_tf_runtime_expr(expr)) + value = self._coerce_int_slot( + value, expr, self._int_slot_cpp_type(None, cpp_type)) + lines.append(f"{' ' * indent}{target} = {value};") + + def emit(selection, indent): + pad = " " * indent + if isinstance(selection, SwitchStmt): + selector = None + if selection.expr is not None: + selector = f"__switch_val_{self._switch_counter}" + self._switch_counter += 1 + selector_type = ("std::string" if self._infer_type(selection.expr) + == "std::string" else "auto") + selector_value = self._security_selection_selector_cpp( + selection.expr, self._security_tf_runtime_expr(selection.expr)) + lines.append( + f"{pad}{selector_type} {selector} = " + f"{selector_value};") + for index, (case, body) in enumerate(selection.cases): + prefix = "if" if index == 0 else "else if" + case_value = self._security_tf_runtime_expr(case) + condition = ( + f"{selector} == {case_value}" if selector is not None else + self._coerce_bool_expr(case_value, case)) + lines.append(f"{pad}{prefix} ({condition}) {{") + arm(body, indent + 1) + lines.append(f"{pad}}}") + if selection.default_body: + if selection.cases: + lines.append(f"{pad}else {{") + arm(selection.default_body, indent + 1) + lines.append(f"{pad}}}") + else: + arm(selection.default_body, indent) + elif selection.cases: + lines.extend((f"{pad}else {{", f"{pad} {target} = {initial};", + f"{pad}}}")) + else: + lines.append(f"{pad}{target} = {initial};") + return + condition = self._coerce_bool_expr( + self._security_tf_runtime_expr(selection.condition), selection.condition) + lines.append(f"{pad}if ({condition}) {{") + arm(selection.body, indent + 1) + lines.append(f"{pad}}}") + if (len(selection.else_body) == 1 + and isinstance(selection.else_body[0], IfStmt)): + lines[-1] = f"{pad}}} else" + emit(selection.else_body[0], indent) + elif selection.else_body: + lines[-1] = f"{pad}}} else {{" + arm(selection.else_body, indent + 1) + lines.append(f"{pad}}}") + else: + lines.extend((f"{pad}else {{", f"{pad} {target} = {initial};", + f"{pad}}}")) + + emit(node, 1) + lines.extend((f" return {target};", "}())")) + return "\n".join(lines) + def _security_tf_switch_runtime_expr( self, node: SwitchStmt, resolving: set[str] ) -> str: """Render a pure switch expression for evaluator registration. Pine parses ``tf = switch timeframe.period ...`` as a ``SwitchStmt`` - even though it is the right-hand side of a declaration. The regular - expression visitor deliberately does not render statement nodes, so - passing that alias to ``request.security`` previously produced the - truthy placeholder ``/* unknown */`` in generated C++. Registration - only needs the value selection, which can be represented as nested - conditional expressions over setup-time-safe leaves such as + even though it is the right-hand side of a declaration. Its supported + single-expression arms retain their compact conditional rendering. + Registration uses setup-time-safe leaves such as ``script_tf_`` and direct input getters. - Only pure, single-expression arms are accepted here. Other shapes - cannot be registered deterministically and produce a clear codegen - diagnostic. Without a default arm an unmatched switch yields ``na``, + Arm-local bindings have already been substituted into pure values. + Without a default arm an unmatched switch yields ``na``, with which TradingView's ``request.security`` reads the chart's timeframe (tests/test_e2e_popfix_tf_switch_no_default.py replays its tape): the fallback is ``script_tf_``, ``timeframe.period``'s value. @@ -484,6 +617,8 @@ def _substitute_tf_input_reads(self, node, resolving: set[str]): """ if not isinstance(node, ASTNode): return node + if isinstance(node, (IfStmt, SwitchStmt)): + return self._substitute_tf_selection_reads(node, resolving) if isinstance(node, Identifier): name = node.name if name in self._timeframe_period_vars: @@ -570,6 +705,376 @@ def _substitute_tf_input_reads(self, node, resolving: set[str]): return replace(node, object=obj, index=idx) return node + def _security_selection_has_block_arms(self, node) -> bool: + """Whether a selection contains an arm with a statement prefix. + + Inspect authored arm structure before substitution. The same decision + selects block lowering at registration and in requested values. + """ + if not isinstance(node, (IfStmt, SwitchStmt)): + return False + for value, _depth in iter_ast_nodes(node): + if isinstance(value, IfStmt): + bodies = (value.body, value.else_body) + elif isinstance(value, SwitchStmt): + bodies = [body for _, body in value.cases] + [value.default_body] + else: + continue + if any(len(body) > 1 for body in bodies): + return True + return False + + def _substitute_tf_selection_reads(self, node, resolving: set[str]): + """Expand setup-time globals inside a block without replacing locals. + + Retain authored nodes when no leaf changes: binding scopes, call-site + identity and source spans still belong to the analyzed tree. + """ + from dataclasses import fields + + if self._security_selection_has_block_arms(node): + return self._security_selection_arms( + node, registration=True, resolving=resolving) + + def walk(value): + if isinstance(value, Identifier): + if not self._security_identifier_is_global_binding(value): + return value + return self._substitute_tf_input_reads(value, resolving) + if isinstance(value, MemberAccess): + return value + if isinstance(value, ASTNode): + changed = {} + for field in fields(value): + if field.name in ("loc", "annotations", "target", "callee"): + continue + before = getattr(value, field.name) + after = walk(before) + if after is not before: + changed[field.name] = after + return replace(value, **changed) if changed else value + if isinstance(value, (list, tuple)): + after = [walk(item) for item in value] + if all(a is b for a, b in zip(after, value)): + return value + return tuple(after) if isinstance(value, tuple) else after + if isinstance(value, dict): + after = {key: walk(item) for key, item in value.items()} + return (value if all(after[key] is item for key, item in value.items()) + else after) + return value + + return walk(node) + + def _security_selection_refusal(self, node, leaf_class: str, detail: str): + """Use the existing context identities for the first failing leaf.""" + from ..diagnostic_codes import render_diagnostic + + if leaf_class == "series": + code, argument = "PF-E3085", "expr_name" + elif leaf_class in ("name", "call"): + code, argument = "PF-E3078", "expr_name" + else: + code, argument = "PF-E3083", "node_type" + message, hint = render_diagnostic( + code, {"request_name": "security", argument: detail}, + ) + self._codegen_error(node, message, hint=hint) + + def _security_selection_arm_expr(self, body, owner): + if not body: + return NaLiteral(loc=owner.loc) + last = body[-1] + if isinstance(last, ExprStmt): + return last.expr + if isinstance(last, (IfStmt, SwitchStmt)): + return self._security_selection_as_ternary(last) + self._security_selection_refusal(last, "statement", type(last).__name__) + + def _security_selection_as_ternary(self, node): + """The value of normalized arms; no declaration or assignment survives.""" + if isinstance(node, IfStmt): + return Ternary( + condition=node.condition, + true_val=self._security_selection_arm_expr(node.body, node), + false_val=self._security_selection_arm_expr(node.else_body, node), + loc=node.loc, + ) + value = self._security_selection_arm_expr(node.default_body, node) + for case, body in reversed(node.cases): + condition = (case if node.expr is None else + BinOp(left=node.expr, op="==", right=case, loc=case.loc)) + value = Ternary(condition=condition, + true_val=self._security_selection_arm_expr(body, node), + false_val=value, loc=node.loc) + return value + + def _security_selection_selector_cpp(self, expr, cpp: str) -> str: + """A substituted string selector compares string values.""" + return f"std::string({cpp})" if self._infer_type(expr) == "std::string" else cpp + + def _security_block_selection_runtime_expr(self, node, render) -> str: + """Render normalized requested block values, retaining lazy arms.""" + def arm(body): + if body and isinstance(body[-1], (IfStmt, SwitchStmt)): + return self._security_block_selection_runtime_expr(body[-1], render) + return render(self._security_selection_arm_expr(body, node)) + + if isinstance(node, IfStmt): + condition = self._coerce_bool_expr(render(node.condition), node.condition) + return f"(({condition}) ? ({arm(node.body)}) : ({arm(node.else_body)}))" + + result = arm(node.default_body) + selector = (self._security_selection_selector_cpp(node.expr, render(node.expr)) + if node.expr is not None else None) + for case, body in reversed(node.cases): + case_cpp = render(case) + condition = (self._lower_relational( + "==", node.expr, case, selector, case_cpp) if selector is not None + else self._coerce_bool_expr(case_cpp, case)) + result = f"(({condition}) ? ({arm(body)}) : ({result}))" + return result + + def _security_selection_arms(self, node, *, registration: bool, + resolving: set[str] | None = None): + """Substitute local lets in source order, with a fresh scope per arm. + + Registration-safe means every leaf is a constant, input read or + setup-time builtin (timeframe.*, syminfo.*), with no history or + bar-state read. Operators and calls must be pure. The requested-value + consumer runs per bar and therefore also admits series leaves. + Captured values are fully expanded before the next binder enters the + environment; looking one up never substitutes it a second time. + """ + self._security_check_block_local_history(node) + resolving = resolving or set() + functions: set[str] = set() + + def fail(value, kind, detail): + self._security_selection_refusal(value, kind, detail) + + def expression(value, env): + if isinstance(value, Identifier): + if value.name in env: + return env[value.name] + if not registration: + return value + expanded = self._substitute_tf_input_reads(value, resolving) + if expanded is not value: + return expression(expanded, {}) + known = self._known_vars.get(value.name) + if isinstance(known, (int, float, bool)): + literal = BoolLiteral if isinstance(known, bool) else NumberLiteral + return literal(value=known, loc=value.loc) + if value.name in BAR_BUILTINS or value.name in self.ctx.series_vars: + fail(value, "series", f"bar-state read {value.name}") + fail(value, "name", f"undefined or non-setup name {value.name}") + if isinstance(value, (NumberLiteral, StringLiteral, BoolLiteral, + ColorLiteral, NaLiteral)): + return value + if isinstance(value, MemberAccess): + if not registration: + return value + if (isinstance(value.object, Identifier) + and value.object.name in self._SECURITY_TF_REPLAY_MEMBERS): + return value + owner = value.object.name if isinstance(value.object, Identifier) else type(value.object).__name__ + fail(value, "series", f"bar-state member {owner}.{value.member}") + if isinstance(value, Subscript): + if isinstance(value.object, Identifier) and value.object.name in env: + fail(value, "series", "history of a block-local") + if registration: + fail(value, "series", "history reference") + return replace(value, object=expression(value.object, env), + index=expression(value.index, env)) + if isinstance(value, BinOp): + return replace(value, left=expression(value.left, env), + right=expression(value.right, env)) + if isinstance(value, UnaryOp): + return replace(value, operand=expression(value.operand, env)) + if isinstance(value, Ternary): + return replace(value, condition=expression(value.condition, env), + true_val=expression(value.true_val, env), + false_val=expression(value.false_val, env)) + if isinstance(value, TupleLiteral): + return replace(value, elements=[expression(part, env) for part in value.elements]) + if isinstance(value, (IfStmt, SwitchStmt)): + return self._security_selection_as_ternary(selection(value, env)) + if isinstance(value, FuncCall): + name, namespace = self._resolve_callee(value.callee) + info = self._func_info_map.get(name) if namespace is None else None + if info is not None and info.node is not None: + if name in functions: + fail(value, "call", f"recursive call {name}") + params = info.node.params + supplied = dict(zip(params, value.args)) + supplied.update(value.kwargs) + defaulted = self._user_call_args_with_defaults(name, value) + if defaulted is not None: + supplied = dict(zip(params, defaulted)) + if set(supplied) != set(params): + fail(value, "call", f"unbound call {name}") + bound = {param: expression(supplied[param], env) for param in params} + functions.add(name) + try: + return self._security_selection_arm_expr(block(info.node.body, bound), value) + finally: + functions.remove(name) + pure = ( + (namespace is None and name in self._SECURITY_TF_REPLAY_CALLS) + or (namespace in self._SECURITY_TF_REPLAY_NAMESPACES + and (namespace, name) != ("math", "random")) + or (namespace == "timeframe" + and name in self._SECURITY_TF_REPLAY_TIMEFRAME_CALLS) + ) + if not pure: + fail(value, "call", f"call without purity proof {namespace + '.' if namespace else ''}{name}") + # Input defaults/options are descriptors, not runtime reads. + if namespace == "input" or (namespace is None and name == "input"): + return value + return replace(value, args=[expression(arg, env) for arg in value.args], + kwargs={key: expression(arg, env) if isinstance(arg, ASTNode) else arg + for key, arg in value.kwargs.items()}) + fail(value, "statement", type(value).__name__) + + def tuple_elements(value, count, owner): + if isinstance(value, TupleLiteral) and len(value.elements) == count: + return value.elements + if isinstance(value, Ternary): + left = tuple_elements(value.true_val, count, owner) + right = tuple_elements(value.false_val, count, owner) + return [Ternary(condition=value.condition, true_val=a, false_val=b, + loc=value.loc) for a, b in zip(left, right)] + fail(owner, "statement", "tuple declaration with unresolved elements") + + def block(body, outer): + env = dict(outer) + local = set() + result = NaLiteral(loc=node.loc) + for index, stmt in enumerate(body): + last = index == len(body) - 1 + if isinstance(stmt, VarDecl): + if stmt.is_var or stmt.is_varip: + fail(stmt, "statement", "persistent declaration") + result = expression(stmt.value, env) + if stmt.type_hint in ("int", "float", "bool", "string"): + result = FuncCall(callee=Identifier(name=stmt.type_hint, loc=stmt.loc), + args=[result], loc=stmt.loc) + env[stmt.name] = result + local.add(stmt.name) + elif isinstance(stmt, TupleAssign): + result = expression(stmt.value, env) + elements = tuple_elements(result, len(stmt.names), stmt) + for name, part in zip(stmt.names, elements): + if name != "_": + env[name] = part + local.add(name) + elif isinstance(stmt, Assignment): + if not isinstance(stmt.target, Identifier) or stmt.target.name not in local: + fail(stmt, "statement", "outer assignment") + result = expression(stmt.value, env) + if stmt.op != ":=": + result = BinOp(left=env[stmt.target.name], op=stmt.op[:-1], + right=result, loc=stmt.loc) + env[stmt.target.name] = result + elif isinstance(stmt, (IfStmt, SwitchStmt)) and last: + return [selection(stmt, env)] + elif isinstance(stmt, ExprStmt): + result = expression(stmt.expr, env) + else: + fail(stmt, "statement", type(stmt).__name__) + return [ExprStmt(expr=result, loc=result.loc)] + + def arm(body, env): + if not env and len(body) == 1 and isinstance(body[0], ExprStmt): + # Preserve existing scalar arms and stateful TA call identities. + if not registration: + return body + expression(body[0].expr, {}) + return [replace(body[0], expr=self._substitute_tf_input_reads( + body[0].expr, resolving))] + return block(body, env) + + def selection(value, env): + def selector(expr): + lowered = expression(expr, env) + # With no local bindings, keep existing call identities and + # spelling after validating their setup dependencies. + return (self._substitute_tf_input_reads(expr, resolving) + if registration and not env else lowered) + + if isinstance(value, IfStmt): + return replace(value, condition=selector(value.condition), + body=arm(value.body, env), + else_body=arm(value.else_body, env) + if value.else_body else []) + return replace(value, expr=selector(value.expr) if value.expr is not None else None, + cases=[(selector(case), arm(body, env)) + for case, body in value.cases], + default_body=arm(value.default_body, env) + if value.default_body else []) + + return selection(node, {}) + + def _security_check_block_local_history(self, node) -> None: + """Refuse local history before a block's current value is substituted. + + This also guards the statement-emitted if path, which does not use + let substitution. Names enter scope after their initializer; each arm + receives its own scope, including the enclosing arm's locals. + """ + def block(body, outer): + local = set(outer) + for stmt in body: + if isinstance(stmt, VarDecl): + walk(stmt.value, local) + local.add(stmt.name) + elif isinstance(stmt, TupleAssign): + walk(stmt.value, local) + local.update(name for name in stmt.names if name != "_") + else: + walk(stmt, local) + + def walk(value, local): + if isinstance(value, Subscript): + if isinstance(value.object, Identifier) and value.object.name in local: + self._security_selection_refusal( + value, "series", "history of a block-local") + if isinstance(value, IfStmt): + walk(value.condition, local) + block(value.body, local) + block(value.else_body, local) + elif isinstance(value, SwitchStmt): + walk(value.expr, local) + for case, body in value.cases: + walk(case, local) + block(body, local) + block(value.default_body, local) + elif isinstance(value, ForStmt): + walk(value.start, local) + walk(value.end, local) + walk(value.step, local) + block(value.body, local | {value.var}) + elif isinstance(value, ForInStmt): + walk(value.iterable, local) + names = set(value.vars or ([value.var] if value.var else [])) + block(value.body, local | names) + elif isinstance(value, WhileStmt): + walk(value.condition, local) + block(value.body, local) + elif isinstance(value, ASTNode): + for field, child in vars(value).items(): + if field not in ("loc", "annotations"): + walk(child, local) + elif isinstance(value, (list, tuple)): + for child in value: + walk(child, local) + elif isinstance(value, dict): + for child in value.values(): + walk(child, local) + + walk(node, set()) + # Calls a timeframe's first-bar value may make: pure, per-run values. _SECURITY_TF_REPLAY_CALLS = frozenset({"nz", "na", "int", "float", "bool", "string", "input"}) _SECURITY_TF_REPLAY_NAMESPACES = frozenset({"math", "str", "input"}) @@ -3438,6 +3943,14 @@ def nodes(node): def scan_expr(node) -> bool: for child in nodes(node): + if isinstance(child, (IfStmt, SwitchStmt)): + # Only declarations owned by a requested selection use + # its helper-series storage. A top-level var already has + # a dedicated mutable-global copy. + if any(isinstance(local, VarDecl) + and (local.is_var or local.is_varip) + and is_string_decl(local) for local in nodes(child)): + return True if isinstance(child, Identifier): # A payload re-evaluates the globals it reads (and the # statements rebinding a mutable one) on the requested bar. @@ -3621,6 +4134,8 @@ def activate_decl() -> None: lines, ) active_bindings[stmt.name] = binding + if is_persistent_var: + self._security_persistent_helper_series.add(series_name) cpp_type = None if is_persistent_var or self._security_string_series_declared: cpp_type = self._type_for_decl(stmt) @@ -4491,13 +5006,15 @@ def _security_helper_call_plan( assert fi.node is not None body = fi.node.body params = list(fi.node.params) + self._security_check_outer_writes(body, set(), (), helper_params=set(params)) + self._security_require_value(node) - if len(body) == 1 and isinstance(body[0], ExprStmt): + if len(body) == 1 and isinstance(body[0], (ExprStmt, IfStmt)): return { "mode": "expr", "func_info": fi, "binding_stack": bound_stack, - "expr": body[0].expr, + "expr": body[0].expr if isinstance(body[0], ExprStmt) else body[0], "body": [], } @@ -4511,6 +5028,8 @@ def _security_helper_call_plan( final_stmt = body[-1] if isinstance(final_stmt, ExprStmt): final_expr = final_stmt.expr + elif isinstance(final_stmt, IfStmt): + final_expr = final_stmt elif isinstance(final_stmt, Assignment): target_name = self._get_target_name(final_stmt.target) if target_name is None: @@ -6045,6 +6564,22 @@ def _security_lazy_ta_keys( occurrences: dict[tuple[int, tuple], list[bool]] = {} unsafe = False + lexical_reads: set[int] = set() + + def arm(body, binding_stack, resolving): + names = set() + for stmt in body: + # Reads of an already computed local are not another + # evaluation of the global-expression-map entry of its name. + lexical_reads.update( + id(child) for child, _ in iter_ast_nodes(stmt) + if isinstance(child, Identifier) and child.name in names + ) + walk(stmt, True, binding_stack, resolving) + if isinstance(stmt, VarDecl): + names.add(stmt.name) + elif isinstance(stmt, TupleAssign): + names.update(stmt.names) def walk(node, lazy: bool, binding_stack, resolving: set[str]) -> None: nonlocal unsafe @@ -6052,6 +6587,8 @@ def walk(node, lazy: bool, binding_stack, resolving: set[str]) -> None: return if isinstance(node, Identifier): + if id(node) in lexical_reads: + return binding = None if not self._security_identifier_is_global_binding(node): binding = self._security_lookup_helper_binding_context( @@ -6111,7 +6648,11 @@ def walk(node, lazy: bool, binding_stack, resolving: set[str]) -> None: walk(node.true_val, True, binding_stack, resolving) walk(node.false_val, True, binding_stack, resolving) return - + if isinstance(node, IfStmt): + walk(node.condition, lazy, binding_stack, resolving) + arm(node.body, binding_stack, resolving) + arm(node.else_body, binding_stack, resolving) + return site = self._get_ta_site(node) if site is not None: idx = self._ta_index_by_site_id.get(id(site)) @@ -6597,6 +7138,7 @@ def _emit_security_evaluator_body(self, item: dict, lines: list[str]) -> None: """Emit one ``_eval_security_N`` method.""" sec_id = item["sec_id"] expr_node = item["expr_node"] + self._security_require_value(expr_node) info = self._security_eval_info[sec_id] if not self._security_requested_calls: info = self._security_info_without_methods(info) @@ -7216,6 +7758,12 @@ def _build_security_expr( """Build C++ expression for a security evaluator.""" if expr_node is None: return "na()" + selection = getattr(self, "_security_selection_context", None) + if selection is not None and id(expr_node) in selection["reads"]: + return selection["reads"][id(expr_node)] + if (isinstance(expr_node, IfStmt) + and self._security_selection_has_block_arms(expr_node)): + self._security_check_block_local_history(expr_node) column = (getattr(expr_node, "annotations", None) or {}).get(FOOTPRINT_COLUMN_ANNOTATION) if column is not None and self._security_foreign(sec_id): # request.footprint(...) of another symbol: the delta its feed @@ -7229,6 +7777,10 @@ def _build_security_expr( if helper_binding_stack is None: helper_binding_stack = () + if isinstance(expr_node, (IfStmt, SwitchStmt)): + self._security_check_outer_writes( + expr_node, security_mutable_names, helper_binding_stack) + if isinstance(expr_node, Identifier): sample = self._security_sampled_argument(expr_node.name, helper_binding_stack) if sample is not None: @@ -7874,11 +8426,39 @@ def lower(left: str, right: str) -> str: cond = self._coerce_bool_expr(cond, expr_node.condition) return f"(({cond}) ? ({tv}) : ({fv}))" + if isinstance(expr_node, IfStmt): + return self._security_requested_if_expr(expr_node, ( + sec_id, ta_range, ta_results, resolving, security_mutable_names, + helper_binding_stack, emitted_lines, + )) + if isinstance(expr_node, SwitchStmt): + # Pure lets retain substitution. Writes to an enclosing binding + # and persistent declarations need lexical runtime storage, just + # as they do in an if arm. + if any( + (isinstance(child, Assignment) + and isinstance(child.target, Identifier) + and child.target.name in security_mutable_names) + or (isinstance(child, VarDecl) and (child.is_var or child.is_varip)) + for child, _ in iter_ast_nodes(expr_node) + ): + return self._security_requested_if_expr(expr_node, ( + sec_id, ta_range, ta_results, resolving, security_mutable_names, + helper_binding_stack, emitted_lines, + )) # A helper local holding a switch (``ma = switch maType``): the # arm its selector takes, as a ternary chain, which evaluates no # other arm (TradingView's switch does not either). It used to # render as ``/* unknown */``, which did not compile. + if self._security_selection_has_block_arms(expr_node): + normalized = self._security_selection_arms(expr_node, registration=False) + return self._security_block_selection_runtime_expr( + normalized, + lambda value: self._build_security_expr( + sec_id, value, ta_range, ta_results, resolving, + security_mutable_names, helper_binding_stack, emitted_lines), + ) return self._build_security_expr( sec_id, self._security_switch_as_ternary(expr_node), ta_range, ta_results, resolving, security_mutable_names, helper_binding_stack, emitted_lines, @@ -8241,8 +8821,8 @@ def _security_note_chart_read(self, node, reason: str) -> None: def _security_switch_as_ternary(self, node: SwitchStmt): """``node``'s value as nested ternaries: each case's arm when its value equals the selector (or its condition holds, with no - selector), else the default arm, else ``na``. Every arm must be one - expression.""" + selector), else the default arm, else ``na``. This is the original + single-expression route; block arms are dispatched before it.""" def arm(body: list): if len(body) != 1 or not isinstance(body[0], ExprStmt): self._codegen_error( @@ -8281,6 +8861,440 @@ def _security_render_fallback(self, expr_node, args: tuple, chart: set[int], *, self._security_payload_depth -= 1 return self._rewrite_security_cpp(result, sec_id, mutable_names, stack) + def _security_requested_if_expr(self, node: IfStmt | SwitchStmt, args: tuple) -> str: + """Keep the statement emitter, with requested reads and lexical storage. + + Bind reads by AST identity, not by spelling: expanding a global's + initializer must not capture an arm local with the same name. + """ + self._security_check_block_local_history(node) + saved = getattr(self, "_security_selection_context", None) + reads = dict(saved["reads"]) if saved is not None else {} + declarations = {} + states = dict(saved["states"]) if saved is not None else {} + types = dict(saved["types"]) if saved is not None else {} + + def walk(value, env): + if isinstance(value, Identifier): + if value.name in env: + reads[id(value)] = env[value.name] + elif (value.name in args[4] + and self._security_identifier_is_global_binding(value)): + info = self._global_mutable_infos[value.name] + state = self._security_state_name(args[0], value.name) + current = f"{state}[0]" if info.is_series else state + reads[id(value)] = current + types[current] = self._security_cpp_type_for_mutable(value.name, info) + if info.is_series: + states[current] = state + elif isinstance(value, Assignment) and isinstance(value.target, Identifier): + walk(value.value, env) + if value.target.name in env: + reads[id(value.target)] = env[value.target.name] + return + bound = self._security_lookup_helper_binding_context( + value.target.name, args[5]) + if bound is not None and isinstance(bound[0], str): + series = self._security_series_binding_target(bound[0]) + if series in self._security_persistent_helper_series: + current = self._security_helper_series_value(series, "0") + reads[id(value.target)] = current + states[current] = self._security_helper_series_ref(series) + types[current] = ("std::string" if series in self._security_string_series + else "double") + elif value.target.name in args[4] and bound is None: + # Assignment targets are resolved directly by the analyzer, + # so unlike reads they need not carry identifier provenance. + info = self._global_mutable_infos[value.target.name] + state = self._security_state_name(args[0], value.target.name) + current = f"{state}[0]" if info.is_series else state + reads[id(value.target)] = current + types[current] = self._security_cpp_type_for_mutable(value.target.name, info) + if info.is_series: + states[current] = state + elif isinstance(value, IfStmt): + walk(value.condition, env) + block(value.body, env) + block(value.else_body, env) + elif isinstance(value, SwitchStmt): + walk(value.expr, env) + for case, body in value.cases: + walk(case, env) + block(body, env) + block(value.default_body, env) + elif isinstance(value, (ForStmt, ForInStmt, WhileStmt)): + local = dict(env) + if isinstance(value, ForStmt): + for header in (value.start, value.end, value.step): + walk(header, env) + local[value.var] = self._safe_name(value.var) + elif isinstance(value, WhileStmt): + walk(value.condition, env) + else: + walk(value.iterable, env) + for name in (value.vars or [value.var]): + if name: + local[name] = self._safe_name(name) + block(value.body, local) + elif isinstance(value, FuncCall): + walk(value.args, env) + walk(value.kwargs, env) + if isinstance(value.callee, MemberAccess): + walk(value.callee.object, env) + elif isinstance(value, ASTNode): + for key, child in vars(value).items(): + if key not in ("loc", "annotations", "callee"): + walk(child, env) + elif isinstance(value, (list, tuple)): + for child in value: + walk(child, env) + elif isinstance(value, dict): + for child in value.values(): + walk(child, env) + + def block(body, outer): + env = dict(outer) + for stmt in body: + if isinstance(stmt, VarDecl): + walk(stmt.value, env) + name = self._security_next_inline_name(args[0], "selection", stmt.name) + declarations[id(stmt)] = name + cpp_type = self._type_for_decl(stmt) + if stmt.is_var or stmt.is_varip: + if cpp_type == "std::string": + self._security_store_string_series(stmt, name) + ref = self._security_helper_series_ref(name) + current = self._security_helper_series_value(name, "0") + states[current] = ref + env[stmt.name] = current + else: + env[stmt.name] = name + types[env[stmt.name]] = cpp_type + elif isinstance(stmt, TupleAssign): + walk(stmt.value, env) + names = [self._security_next_inline_name(args[0], "selection", name) + for name in stmt.names] + declarations[id(stmt)] = names + env.update((raw, cpp) for raw, cpp in zip(stmt.names, names) if raw != "_") + else: + walk(stmt, env) + + walk(node, {}) + self._security_selection_context = { + "reads": reads, "declarations": declarations, "states": states, + "types": types, "args": args, + } + try: + return self._security_render_fallback( + node, args, set(), render=lambda: self._selection_value_expr(node)) + finally: + self._security_selection_context = saved + + def _security_collection_write_target(self, node): + """Receiver of a mutating collection builtin, in either call syntax.""" + if (not isinstance(node, FuncCall) or not isinstance(node.callee, MemberAccess) + or self._security_user_call_key(node) is not None): + return None + method, namespace = self._resolve_callee(node.callee) + receiver = node.callee.object + spec = self._type_spec_from_expr(receiver) + kind = spec.kind if spec is not None else None + if kind not in {"array", "matrix", "map"}: + if namespace not in {"array", "matrix", "map"}: + return None + kind = namespace + receiver = node.args[0] if node.args else node.kwargs.get("id") + mutators = { + "array": ARRAY_JOURNAL_DELTA_METHODS | ARRAY_JOURNAL_FULL_METHODS, + "matrix": MATRIX_VOID_METHODS | {"remove_row", "remove_col", "concat"}, + "map": {"put", "put_all", "remove", "clear"}, + } + return receiver if method in mutators[kind] else None + + def _security_check_outer_writes( + self, node, mutable_names, binding_stack, *, helper_params=None, + ) -> None: + """Require request-owned persistence for a selection's outer stores.""" + from ..diagnostic_codes import render_diagnostic + + helper = helper_params is not None + saved = getattr(self, "_security_selection_context", None) + active_calls = set() + + def origin(value, local): + while isinstance(value, (MemberAccess, Subscript)): + value = value.object + if isinstance(value, Identifier): + return local.get(value.name, value) + return None + + def check(target, at, local, *, assignment=False): + while isinstance(target, (MemberAccess, Subscript)): + target = target.object + if not isinstance(target, Identifier): + return + if target.name in local: + if assignment or local[target.name] is None: + return + target = local[target.name] + if helper: + # Collections may be mutated by functions in Pine; rebinding + # a global variable from a function is forbidden. + if not assignment: + return + if target.name not in self.ctx.symbols.global_scope.symbols: + return + code = "PF-E3104" + else: + if saved is not None and id(target) in saved["reads"]: + # A nested selection can still write a declaration owned + # by the enclosing requested selection. + return + info = self._global_mutable_infos.get(target.name) + bound = self._security_lookup_helper_binding_context(target.name, binding_stack) + if (bound is None and target.name in mutable_names + and info is not None and info.is_var): + return + # A helper's persistent local is already bound to its + # request-owned series; ordinary prefix locals are not. + if bound is not None and isinstance(bound[0], str): + series = self._security_series_binding_target(bound[0]) + if series in self._security_persistent_helper_series: + return + code = "PF-E3103" + message, hint = render_diagnostic(code, {}) + self._codegen_error(at, message, hint=hint) + + def block(body, outer): + local = dict(outer) + for stmt in body or []: + walk(stmt, local) + if isinstance(stmt, VarDecl): + local[stmt.name] = origin(stmt.value, local) + elif isinstance(stmt, TupleAssign): + local.update((name, None) for name in stmt.names) + elif isinstance(stmt, Assignment) and isinstance(stmt.target, Identifier): + if stmt.target.name in local: + local[stmt.target.name] = origin(stmt.value, local) + + def walk(value, local): + if isinstance(value, Assignment): + check(value.target, value, local, + assignment=isinstance(value.target, Identifier)) + walk(value.value, local) + elif isinstance(value, IfStmt): + walk(value.condition, local) + block(value.body, local) + block(value.else_body, local) + elif isinstance(value, SwitchStmt): + walk(value.expr, local) + for case, body in value.cases: + walk(case, local) + block(body, local) + block(value.default_body, local) + elif isinstance(value, (ForStmt, ForInStmt, WhileStmt)): + inner = dict(local) + if isinstance(value, ForStmt): + for header in (value.start, value.end, value.step): + walk(header, local) + inner[value.var] = None + elif isinstance(value, ForInStmt): + walk(value.iterable, local) + inner.update((name, None) for name in (value.vars or [value.var])) + else: + walk(value.condition, local) + block(value.body, inner) + elif isinstance(value, FuncCall): + target = self._security_collection_write_target(value) + if target is not None: + check(target, value.callee.object, local) + key = self._security_user_call_key(value) + if not helper and key is not None and key not in active_calls: + info, frames = self._security_bind_helper_args(value, binding_stack) + self._security_check_outer_writes( + info.node.body, set(), (), helper_params=set(info.node.params)) + arguments = frames[-1] + parameters = {name: origin(arguments.get(name), local) + for name in info.node.params} + active_calls.add(key) + try: + block(info.node.body, parameters) + finally: + active_calls.remove(key) + walk(value.args, local) + walk(value.kwargs, local) + elif isinstance(value, ASTNode): + for key, child in vars(value).items(): + if key not in ("loc", "annotations", "callee"): + walk(child, local) + elif isinstance(value, (list, tuple)): + for child in value: + walk(child, local) + elif isinstance(value, dict): + for child in value.values(): + walk(child, local) + + if helper: + block(node, dict.fromkeys(helper_params)) + else: + walk(node, {}) + + def _security_require_value(self, node) -> None: + """Reject void results before a default-valued emitter can hide them.""" + from ..diagnostic_codes import render_diagnostic + + expressions = getattr(self.ctx, "global_expr_map", {}) or {} + functions = {} + + def block(body, bindings, active): + if not body: + return True + local = dict(bindings) + for stmt in body[:-1]: + if isinstance(stmt, VarDecl): + local[stmt.name] = stmt.value + elif isinstance(stmt, Assignment) and isinstance(stmt.target, Identifier): + local[stmt.target.name] = stmt.value + return void(body[-1], local, active) + + def void(value, bindings, active): + if value is None or id(value) in active: + return False + active = active | {id(value)} + if isinstance(value, ExprStmt): + return void(value.expr, bindings, active) + if isinstance(value, (VarDecl, Assignment)): + return void(value.value, bindings, active) + if isinstance(value, Identifier): + bound = bindings.get(value.name) + if bound is None and self._security_identifier_is_global_binding(value): + bound = expressions.get(value.name) + return void(bound, bindings, active) if bound is not None else False + if isinstance(value, (IfStmt, SwitchStmt)): + arms = ([value.body, value.else_body] if isinstance(value, IfStmt) + else [body for _, body in value.cases] + [value.default_body]) + # An omitted arm supplies na; an authored void arm supplies no + # value and cannot be substituted for that missing value. + present = [body for body in arms if body] + return not present or any(block(body, bindings, active) for body in present) + if isinstance(value, Ternary): + return (void(value.true_val, bindings, active) + or void(value.false_val, bindings, active)) + if isinstance(value, TupleLiteral): + return any(void(part, bindings, active) for part in value.elements) + if isinstance(value, FuncCall): + if any(void(arg, bindings, active) + for arg in (*value.args, *value.kwargs.values()) + if isinstance(arg, ASTNode)): + return True + key = self._security_user_call_key(value) + if key is not None: + info = self._func_info_map[key] + if info.node is not None: + if key not in functions: + functions[key] = block(info.node.body, {}, active) + return functions[key] + name, namespace = self._resolve_callee(value.callee) + return (self._call_is_void(value) or self._is_skip_expr(value) + or sigs.get_return_type(namespace, name, len(value.args)) == PineType.VOID) + return False + + if void(node, {}, set()): + message, hint = render_diagnostic("PF-E3102", {}) + self._codegen_error(node.callee if isinstance(node, FuncCall) else node, + message, hint=hint) + + def _emit_security_selection_stmt(self, node, lines, pad) -> bool: + """Emit a selection's bindings without touching chart member storage.""" + context = getattr(self, "_security_selection_context", None) + if context is None: + return False + args = context["args"] + + def build(value): + # Statements emitted by an inlined helper belong inside this arm, + # rather than ahead of the selection in the evaluator prologue. + local_lines = [] + cpp = self._build_security_expr(args[0], value, *args[1:6], local_lines) + lines.extend(pad + line for line in local_lines) + return cpp + + if isinstance(node, VarDecl) and id(node) in context["declarations"]: + name = context["declarations"][id(node)] + cpp_type = self._type_for_decl(node) + value = build(node.value) + value = self._security_local_value(value, node.value, cpp_type, args[5]) + if node.is_var or node.is_varip: + ref = self._security_helper_series_ref(name) + seed = self._security_helper_series_ref(f"{name}@var_seed") + lines.extend(( + f"{pad}if ({ref}.size() == 0) {{", + f"{pad} {seed}.push({value});", + f"{pad} {ref}.push({seed}[0]);", + f"{pad}}} else if (security_series_slot_is_new({args[0]})) {{", + f"{pad} {ref}.push({ref}[0]);", + f"{pad}}} else {{", + f"{pad} {ref}.update({ref}.size() > 1 ? {ref}[1] : {seed}[0]);", + f"{pad}}}", + )) + return True + lines.append(f"{pad}{cpp_type} {name} = {value};") + return True + if isinstance(node, TupleAssign) and id(node) in context["declarations"]: + value = build(node.value) + temporary = self._security_next_inline_name(args[0], "selection", "tuple") + lines.append(f"{pad}auto {temporary} = {value};") + fields = self._security_tuple_value_fields(node.value, args[5]) + for index, (raw, name) in enumerate(zip(node.names, context["declarations"][id(node)])): + if raw != "_": + element = (f"{temporary}.{fields[index]}" if fields is not None + else f"std::get<{index}>({temporary})") + lines.append(f"{pad}auto {name} = {element};") + return True + if isinstance(node, Assignment) and id(node.target) in context["reads"]: + name = context["reads"][id(node.target)] + value = build(node.value) + rhs = (value if node.op == ":=" + else self._compound_assign_rhs(name, node.op, value)) + if rhs is not None: + cpp_type = context["types"].get(name) + if node.op == ":=": + rhs = self._security_local_value(rhs, node.value, cpp_type, args[5]) + elif cpp_type in ("int", "int64_t"): + rhs = self._coerce_int_slot( + rhs, node.value, cpp_type, value_is_double=True) + ref = context["states"].get(name) + lines.append(f"{pad}{ref}.update({rhs});" if ref is not None + else f"{pad}{name} = {rhs};") + else: + lines.append(f"{pad}{name} {node.op} {value};") + return True + return False + + def _security_selection_local_member_names(self) -> set[str]: + """Arm-only ordinary declarations need no chart member storage.""" + expressions = getattr(self.ctx, "global_expr_map", {}) or {} + pending = [node for info in self._security_eval_info + for node in (info.get("expr_node"), info.get("tf_node"), + info.get("symbol_node")) if node is not None] + # The evaluator records keep the original payload in the call table. + pending.extend(item.get("expr_node") for item in self._security_calls) + seen = set() + names = set() + while pending: + root = pending.pop() + if not isinstance(root, ASTNode) or id(root) in seen: + continue + seen.add(id(root)) + for node, _ in iter_ast_nodes(root): + if isinstance(node, (IfStmt, SwitchStmt)): + names.update(child.name for child, _ in iter_ast_nodes(node) + if isinstance(child, VarDecl) + and not child.is_var and not child.is_varip) + elif isinstance(node, Identifier) and node.name in expressions: + pending.append(expressions[node.name]) + return names - self._direct_program_binding_names - set(self.ctx.series_vars) + def _security_root_chart_read( self, root, @@ -8506,6 +9520,24 @@ def _security_fallback_owns( history (re-evaluating ``g`` on the requested bar would pair it with the chart's ``g[1]``), and ``timeframe.*`` (beside the chart's ``timeframe.in_seconds()``).""" + selection = getattr(self, "_security_selection_context", None) + if selection is not None: + if id(node) in selection["reads"]: + return True + if isinstance(node, IfStmt): + return True + if isinstance(node, Identifier) and ( + (self._security_is_bar_index(node) + and sec_id in self._security_bar_index_secs) + or (self._security_identifier_is_global_binding(node) + and node.name in (getattr(self.ctx, "global_expr_map", {}) or {}))): + return True + if isinstance(node, Identifier) and node.name in ( + set(SECURITY_BAR_FIELD_EXPRS) | {"hl2", "hlc3", "ohlc4", "time_close"}): + return True + if (isinstance(node, MemberAccess) and isinstance(node.object, Identifier) + and node.object.name == "timeframe"): + return self._build_security_timeframe_member(sec_id, node.member) is not None if not self._security_requested_calls: # Every earlier build's lowering, but an input read while a TA # history index or constructor argument is lowered keeps its diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index b4d656ef..e1eee5ac 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -4447,6 +4447,8 @@ def _infer_type( last = node.body[-1] if isinstance(last, ExprStmt): return self._infer_type(last.expr) + if isinstance(last, (IfStmt, SwitchStmt)): + return self._infer_type(last) return "double" if isinstance(node, SwitchStmt): if self._selection_contains_na(node): @@ -4466,6 +4468,8 @@ def _infer_type( last = case_body[-1] if isinstance(last, ExprStmt): return self._infer_type(last.expr) + if isinstance(last, (IfStmt, SwitchStmt)): + return self._infer_type(last) return "double" return "double" diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 05ac03dd..d163fb33 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -89,7 +89,7 @@ from .run_stops import request_stop -from ..errors import Phase +from ..errors import CodegenInvariantError, Phase from ..symbols import TypeSpec from ..external_requests import UNPINNED_ANNOTATION from ..ast_nodes import ( @@ -98,17 +98,24 @@ BinOp, BoolLiteral, ColorLiteral, + ExprStmt, + ForInStmt, + ForStmt, FuncCall, Identifier, + IfStmt, MemberAccess, NaLiteral, NumberLiteral, StrategyDecl, StringLiteral, Subscript, + SwitchStmt, Ternary, TupleLiteral, UnaryOp, + VarDecl, + WhileStmt, ) from .strategy_declaration import DECLARATION_CONSTANTS, NamedConstant from .helpers import pine_index_int_cast, unary_sign_cpp @@ -255,7 +262,7 @@ class ExprVisitor: def _visit_expr(self, node: ASTNode | None) -> str: if node is None: - return "/* null */" + raise CodegenInvariantError(node) if self._budget is not None: self._budget_visit_count += 1 if self._budget_visit_count % 128 == 0: @@ -307,7 +314,87 @@ def _visit_expr(self, node: ASTNode | None) -> str: ) elems = ", ".join(elems) return f"std::make_tuple({elems})" - return "/* unknown */" + if isinstance(node, (IfStmt, SwitchStmt)): + return self._selection_value_expr(node) + raise CodegenInvariantError(node) + + def _selection_value_expr(self, node: IfStmt | SwitchStmt) -> str: + """Materialize an admitted block value with the typed statement emitter. + + The lambda preserves branch laziness and evaluates a switch selector + once. Its target supplies the same typed missing value as a declaration. + """ + cpp_type, initial = self._selection_expression_slot(node) + target = f"__selection_value_{self._switch_counter}" + self._switch_counter += 1 + lines = [ + f"([&]() -> {cpp_type} {{", + f" {cpp_type} {target} = {initial};", + ] + frame = self._security_fallback_frame + saved_args = None + if getattr(self, "_security_selection_context", None) is not None and frame is not None: + # An inlined helper's statements must sit inside this lambda. + saved_args = frame["args"] + frame["args"] = saved_args[:6] + (lines,) + try: + self._visit_selection_value( + node, target, lines, 1, + target_cpp_type=cpp_type, slot_cpp_type=cpp_type, + ) + finally: + if saved_args is not None: + frame["args"] = saved_args + lines.extend((f" return {target};", "}())")) + return "\n".join(lines) + + def _selection_expression_slot(self, node) -> tuple[str, str]: + """The actual result type and missing value of a materialized block.""" + pending = [node] + types = [] + while pending: + value = pending.pop() + if value is None or self._selection_node_is_na(value): + continue + if isinstance(value, IfStmt): + pending.extend(self._selection_terminal_expr(body) + for body in (value.body, value.else_body)) + continue + if isinstance(value, SwitchStmt): + pending.extend(self._selection_terminal_expr(body) + for _, body in value.cases) + pending.append(self._selection_terminal_expr(value.default_body)) + continue + if isinstance(value, (ForStmt, ForInStmt, WhileStmt)): + pending.append(self._selection_terminal_expr(value.body)) + continue + if isinstance(value, TupleLiteral): + elements = self._infer_selection_tuple_types(node, len(value.elements)) + return (f"std::tuple<{', '.join(elements)}>", + self._tuple_default_expr(elements)) + if isinstance(value, FuncCall) and isinstance(value.callee, Identifier): + info = self._func_info_map.get(value.callee.name) + if info is not None and info.returns_tuple: + elements = self._infer_selection_tuple_types( + node, info.tuple_element_count, + ) + return (f"std::tuple<{', '.join(elements)}>", + self._tuple_default_expr(elements)) + spec = self._type_spec_from_expr(value) + types.append(self._type_spec_to_cpp(spec) + if spec is not None and spec.kind != "primitive" + else self._tail_value_cpp_type( + value if isinstance(value, (Assignment, VarDecl)) + else ExprStmt(expr=value)) + or self._infer_type(value)) + if types and set(types) <= {"int", "int64_t", "double"}: + cpp_type = ("double" if "double" in types else + "int64_t" if "int64_t" in types else "int") + else: + cpp_type = types[0] if types else self._infer_type(node) + initial = (self._na_value_for_type(cpp_type) + if cpp_type in self._UNMATCHED_NA_SLOTS else f"{cpp_type}{{}}") + return cpp_type, initial # A ``?:`` chain continued in its false arms (``a ? x : b ? y : z``) of at # least this many links is emitted without the per-link brackets: they add diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index e10919ee..825c0f8c 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -130,6 +130,8 @@ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None: if self._budget_visit_count % 128 == 0: self._budget.check(node.loc, Phase.CODEGEN) pad = " " * indent + if self._emit_security_selection_stmt(node, lines, pad): + return if isinstance(node, StrategyDecl): return @@ -1673,10 +1675,14 @@ def _visit_block_statements(self, body: list, lines: list[str], indent: int) -> None: """Emit one lexical branch/loop body with its exact var remap.""" saved = self._push_block_var_remap(body) + scope = (self._security_open_scope() + if getattr(self, "_security_selection_context", None) is not None else None) try: for stmt in body: self._visit_stmt(stmt, lines, indent) finally: + if scope is not None: + self._security_scope_path = scope self._pop_block_var_remap(saved) def _emit_block_with_assign( @@ -1689,6 +1695,8 @@ def _emit_block_with_assign( ) -> None: """Expression-body counterpart of :meth:`_visit_block_statements`.""" saved = self._push_block_var_remap(body) + scope = (self._security_open_scope() + if getattr(self, "_security_selection_context", None) is not None else None) try: self._emit_body_with_assign( body, @@ -1698,6 +1706,8 @@ def _emit_block_with_assign( target_cpp_type=target_cpp_type, ) finally: + if scope is not None: + self._security_scope_path = scope self._pop_block_var_remap(saved) def _visit_if(self, node: IfStmt, lines: list[str], indent: int) -> None: @@ -2411,6 +2421,18 @@ def _emit_body_with_assign( for i, stmt in enumerate(body): if i == len(body) - 1: # Last statement — try to turn into assignment + context = getattr(self, "_security_selection_context", None) + if (context is not None and isinstance(stmt, VarDecl) + and id(stmt) in context["declarations"]): + self._visit_stmt(stmt, lines, indent) + name = context["declarations"][id(stmt)] + cpp = (self._security_helper_series_value(name, "0") + if stmt.is_var or stmt.is_varip else name) + cpp = self._coerce_int_slot( + cpp, stmt.value, self._int_slot_cpp_type(None, target_cpp_type)) + lines.append( + f"{' ' * indent}{self._array_journal_initializer(target, cpp)}") + return if isinstance(stmt, ExprStmt): # Check if it's a skip expr if self._is_skip_expr(stmt.expr): diff --git a/pineforge_codegen/diagnostics_catalog.json b/pineforge_codegen/diagnostics_catalog.json index a72366ca..c50c6d5e 100644 --- a/pineforge_codegen/diagnostics_catalog.json +++ b/pineforge_codegen/diagnostics_catalog.json @@ -318,6 +318,10 @@ "PF-E3099": {"severity": "error", "area": "request-security", "message": "derived helper-parameter history cannot replay this mutable global on requested-bar recomputation", "hint": null, "explanation": "A recurrent mutable global producer lacks proven requested-slot rollback; an unconditional overwrite independent of prior state is supported.", "args": {}, "user_message": "This mutable global cannot yet be replayed safely when the requested bar is recomputed."}, "PF-E3100": {"severity": "error", "area": "request-security", "message": "derived helper-parameter history currently supports numeric scalar arguments only", "hint": null, "explanation": "The new requested argument store holds numeric scalar samples; reference, string and boolean samples need typed storage.", "args": {}, "user_message": "History of a derived helper argument currently supports numeric scalars only."}, "PF-E3101": {"severity": "error", "area": "request-security", "message": "this parameter type or width has no lossless requested argument-history store", "hint": null, "explanation": "An integer argument without a proven exact double representation needs a lossless typed requested history store.", "args": {}, "user_message": "This argument needs a lossless history store for its integer type or width."}, + "PF-E3102": {"severity": "error", "area": "request-security", "message": "request.security expression must yield a value; a void result cannot be requested", "hint": null, "explanation": "A request expression must return a value, including through helpers and selection arms. A terminal void call cannot supply it; TradingView refuses a void argument (CE10123) and an if or switch whose blocks return incompatible types (CE10235).", "args": {}, "user_message": "A request expression must return a value."}, + "PF-E3103": {"severity": "error", "area": "request-security", "message": "request.security selection cannot write an outer binding without request-persisted var or varip storage", "hint": null, "explanation": "Assignments and collection mutations in a requested selection need request-persisted storage when their target is declared outside the selection. Plain outer writes are an unsupported compile-agreement gap.", "args": {}, "user_message": "Writing this outer binding in a requested selection is unsupported."}, + "PF-E3104": {"severity": "error", "area": "request-security", "message": "Cannot modify a global variable in a function (TradingView CE10088)", "hint": null, "explanation": "Pine functions cannot assign to global variables. This also applies to helpers evaluated as request expressions.", "args": {}, "user_message": "A function cannot assign to a global variable."}, + "PF-E3105": {"severity": "error", "area": "request-security", "message": "a requested timeframe selection with an empty-string arm (the chart''s own timeframe) is not supported yet because its value against TradingView is not measured", "hint": null, "explanation": "An empty-string arm selects the chart's own timeframe. Its value against TradingView has not been measured for requested if/switch selections, including selections read through globals or arm-local bindings. Scalar ternaries keep their existing lowering.", "args": {}, "user_message": "A requested timeframe selection with an empty-string arm (the chart''s own timeframe) is not supported yet because its value against TradingView is not measured."}, "PF-E4001": {"severity": "error", "area": "libraries", "message": "Import is not supported: ''{path}'': library ''{path}'' has no valid sha256 in {where}", "hint": null, "explanation": "The imported library cannot be inlined: its pinned source is missing, unreadable or not configured for this script.", "args": {"path": {"kind": "identifier"}, "where": {"kind": "text"}}, "user_message": "The imported library cannot be inlined: its pinned source is missing, unreadable or not configured for this script."}, "PF-E4002": {"severity": "error", "area": "libraries", "message": "Import is not supported: ''{path}'': library ''{path}'' is not among the libraries passed to transpile()", "hint": null, "explanation": "The imported library was not among the library sources supplied to the transpiler, so it cannot be inlined; supply its source text.", "args": {"path": {"kind": "identifier"}}, "user_message": "The imported library was not among the library sources supplied to the transpiler, so it cannot be inlined."}, "PF-E4003": {"severity": "error", "area": "libraries", "message": "Import is not supported: ''{path}'': library ''{path}'' is not open-source (access ''{access}''); PineForge inlines open libraries only", "hint": null, "explanation": "The library's manifest entry says it is not open-source, and PineForge inlines open libraries only.", "args": {"path": {"kind": "identifier"}, "access": {"kind": "identifier"}}, "user_message": "The library''s manifest entry says it is not open-source, and PineForge inlines open libraries only."}, @@ -440,6 +444,7 @@ "PF-E5080": {"severity": "error", "area": "codegen", "message": "strategy(): too many positional arguments (expected {expected_count}, got {actual_count})", "hint": null, "explanation": "A strategy declaration has more positional values than its signature accepts. Remove the extra values or use the declared parameter names.", "args": {"expected_count": {"kind": "number"}, "actual_count": {"kind": "number"}}, "user_message": "The strategy declaration has too many positional arguments."}, "PF-E5081": {"severity": "error", "area": "codegen", "message": "strategy(): argument ''{name}'' passed both positionally and by keyword", "hint": null, "explanation": "A strategy declaration supplies the same parameter by position and by name. Supply each parameter only once.", "args": {"name": {"kind": "identifier"}}, "user_message": "Strategy argument ''{name}'' is supplied twice."}, "PF-E5082": {"severity": "error", "area": "codegen", "message": "strategy(): setting ''{name}'' must be a compile-time constant", "hint": null, "explanation": "A strategy declaration setting depends on a series, input, mutable variable or unresolved name. Supply a literal or an expression over earlier constants.", "args": {"name": {"kind": "identifier"}}, "user_message": "Strategy setting ''{name}'' must be a compile-time constant."}, + "PF-E5083": {"severity": "error", "area": "codegen", "message": "Internal error: no value lowering for AST node ''{node_kind}''.", "hint": "Report this translator error to PineForge.", "explanation": "An AST value reached code generation without an implementation. This is a translator defect; report the source and the located diagnostic.", "args": {"node_kind": {"kind": "type"}}, "user_message": "The translator could not generate this value. Report the source and diagnostic to PineForge."}, "PF-E6001": {"severity": "error", "area": "collection-history", "message": "matrix.{method}: wrong number of arguments", "hint": "Check Pine v6 matrix method signature (positional vs keyword).", "explanation": "A matrix method call has the wrong number of arguments; check the Pine v6 matrix method signature and whether arguments are positional or keyword.", "args": {"method": {"kind": "identifier"}}, "user_message": "A matrix method call has the wrong number of arguments."}, "PF-E6002": {"severity": "error", "area": "collection-history", "message": "{label} := ...: an assignment to the history-referencing operator is not supported in PineForge.", "hint": null, "explanation": "TradingView refuses an assignment to a history reference; assign the variable itself.", "args": {"label": {"kind": "text"}}, "user_message": "TradingView refuses an assignment to a history reference."}, "PF-E6003": {"severity": "error", "area": "collection-history", "message": "{label} is a {kind}, which TradingView refuses for operator {op} (CE10123).", "hint": null, "explanation": "An array or matrix history read such as a[1] is an operand of an operator, which TradingView refuses; read an element or the size and use that instead.", "args": {"label": {"kind": "identifier"}, "kind": {"kind": "vocab", "values": ["array", "matrix"]}, "op": {"kind": "keyword"}}, "user_message": "An array or matrix history read such as a[1] is an operand of an operator, which TradingView refuses."}, diff --git a/pineforge_codegen/errors.py b/pineforge_codegen/errors.py index b5362e4a..1d4e6396 100644 --- a/pineforge_codegen/errors.py +++ b/pineforge_codegen/errors.py @@ -17,6 +17,23 @@ class Phase(Enum): CODEGEN = "CODEGEN" +class CodegenInvariantError(BaseException): + """An AST value reached emission without a lowering implementation. + + Resolution fallbacks catch Exception to retry ordinary expressions. + A missing lowering must escape those retries to the translation boundary. + _generate, the only production caller that constructs CodeGen, is the + only boundary: it catches this across construction and generation and + exposes a located CompileError, or an explicit internal error if the node + has no source location. Direct CodeGen users must handle it themselves. + """ + + def __init__(self, node): + self.node = node + self.node_kind = type(node).__name__ + super().__init__(f"No value lowering for AST node {self.node_kind}") + + @dataclass class SourceLocation: file: str diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index f3f5b335..690271d3 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -13,6 +13,7 @@ import math import re +from contextlib import contextmanager from .lexer import Token, TokenType from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation @@ -82,6 +83,9 @@ def __init__(self, tokens: list[Token], *, source: str = "", filename: str = " ASTNode: node.loc = self._loc(tok) return node + @contextmanager + def _expression_slot(self, role: str): + """Parse authored values under their parent's expression grammar.""" + self._expression_slots.append(role) + try: + yield + finally: + self._expression_slots.pop() + + def _parse_expression_in_slot(self, role: str): + with self._expression_slot(role): + return self._parse_expression() + # Nesting budget. Recursive descent spends Python frames per level, and # the tree it builds must stay shallow: freeing a tree some 4,000 levels # deep overflows Pyodide's stack, fatally. @@ -1050,7 +1067,7 @@ def _parse_type_decl(self, start_tok): default = None if self._check(TokenType.EQUALS) and self._peek().type != TokenType.EQUALS: self._advance() # = - default = self._parse_expression() + default = self._parse_expression_in_slot("type field default") fields.append(TypeField(type_name=type_name, name=field_name, default=default)) self._skip_newlines() self._consume(TokenType.DEDENT) @@ -1073,7 +1090,7 @@ def _parse_enum_decl(self, start_tok): if (self._check(TokenType.EQUALS) and self._peek().type != TokenType.EQUALS): self._advance() # = - member_values[mname] = self._parse_expression() + member_values[mname] = self._parse_expression_in_slot("enum title") else: self._advance() # skip unexpected tokens self._skip_newlines() @@ -1208,9 +1225,9 @@ def _parse_for_stmt(self): # Traditional: for var = start to end [by step] self._consume(TokenType.EQUALS) - start = self._parse_expression() + start = self._parse_expression_in_slot("for start") self._consume(TokenType.TO) - end = self._parse_expression() + end = self._parse_expression_in_slot("for end") step = None if self._match(TokenType.BY): step = self._parse_expression() @@ -1299,6 +1316,10 @@ def _parse_arm_line(self) -> list: (``tests/fixtures/tail_f_tv``). A lone expression is the node an arm always held. """ + with self._expression_slot("inline switch arm"): + return self._parse_arm_simple_statements() + + def _parse_arm_simple_statements(self) -> list: first_token = self._current() body: list = [] while True: @@ -1420,6 +1441,12 @@ def _parse_block_statements(self) -> list: def _parse_expression(self): self._enter(self._current()) try: + if (self._expression_slots + and self._current().type in (TokenType.IF, TokenType.SWITCH)): + cur = self._current() + raise ParseError( + f"Unexpected token {cur.type.name}({cur.value!r})", cur, + ) # if/switch can be used as expressions (RHS of assignments) if self._check(TokenType.IF): return self._parse_if_expr() @@ -1442,11 +1469,11 @@ def _parse_ternary(self): expr = self._parse_or() if self._match(TokenType.QUESTION): self._skip_newlines() - true_val = self._parse_expression() + true_val = self._parse_expression_in_slot("ternary operand") self._skip_newlines() self._consume(TokenType.COLON, "Expected ':' in ternary") self._skip_newlines() - false_val = self._parse_expression() + false_val = self._parse_expression_in_slot("ternary operand") node = Ternary(condition=expr, true_val=true_val, false_val=false_val) return self._set_loc(node, start_tok) return expr diff --git a/tests/fixtures/diagnostic_codes_pin.json b/tests/fixtures/diagnostic_codes_pin.json index e092cd0d..a4295b73 100644 --- a/tests/fixtures/diagnostic_codes_pin.json +++ b/tests/fixtures/diagnostic_codes_pin.json @@ -319,6 +319,10 @@ "PF-E3099": "265534ccec5def6786e7f40e64789323ca007a41e7a009342e1b25b0a926f569", "PF-E3100": "eb8dcc91e889f3ef9b0de6b09bf91e8780b9daf64e42157d654ce9f5199a48c9", "PF-E3101": "6e949ef2d2ec26f94b1cac70376ee34adf2dd7591e74b3caf0dd5a92997fc011", + "PF-E3102": "35c82bb66ccc9cfd33c0bdecb9023000467e9eaaf78641a8841e42f94e3b215e", + "PF-E3103": "638b6a244b8bcd77ca4604d83b05869de865a907e7640f4f3637a97fa0906bd6", + "PF-E3104": "277b166e7a014ec41fdcbea635d229f640d3dfd7f99707f310cf2e0dc5d68bb4", + "PF-E3105": "76425aab345ef218ea414e01627112de8b7d0afe42029af20070ae42a8301846", "PF-E4001": "f488e6af6b2b4f79cd67174b1e20c64a341f2662890bf94fc0238548bb2ffb32", "PF-E4002": "9e14403bc1e30a4a59b40f55fa0be4f45d27cf31bea9adc97aa71cf599d39ab0", "PF-E4003": "a29480e8883ce324fab677201938a847c6c51ac817c9f6be9d2173f2d7e0c530", @@ -441,6 +445,7 @@ "PF-E5080": "1ef849362c643b036575cf396d07d91aeba4a4cf9e7305e11ae3a7c595738a72", "PF-E5081": "e338297ea8309afb46a3004bcf46b3549282cf58ac83bb4466e25603ea69c172", "PF-E5082": "be588e59481fcbe1ce12b517e763a7fcffc31cacb099485b6d680ec5a5ed7182", + "PF-E5083": "19d57ee04a0c1d42e70fd01bb64bb0d410b78d508bf2b8f1c4bac0ca146549f3", "PF-E6001": "62ad4d403a565609c34596b1c5d3982b990b137e255b3a83a97d92699ebe67bb", "PF-E6002": "655927199576b7c06dae674bcfc6f5750c801f1af0564e862894c1e47a211038", "PF-E6003": "8e9601e1627f343fada36b306aa9ecdd3239161eb3dc6a0716a664dc77e32b1b", diff --git a/tests/fixtures/selection_values/block-assignment-switch.pine b/tests/fixtures/selection_values/block-assignment-switch.pine new file mode 100644 index 00000000..dcd2dadd --- /dev/null +++ b/tests/fixtures/selection_values/block-assignment-switch.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Block reassignment from switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: assignment uses selection emission; even/odd within n%3=0 yields 1/2, else 3. +value = 0.0 +switch bar_index % 3 + 0 => + value := switch bar_index % 2 + 0 => 1.0 + => 2.0 + => value := 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-case-if.pine b/tests/fixtures/selection_values/block-case-if.pine new file mode 100644 index 00000000..7883f65e --- /dev/null +++ b/tests/fixtures/selection_values/block-case-if.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Block case if", currency = currency.NONE, initial_capital = 1000000) +// Derived: the bare IfStmt tail selects 1/2 for n%3=0 and leaves the outer default at 3. +value = switch bar_index % 3 + 0 => + if bar_index % 2 == 0 + 1.0 + else + 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-case-switch.pine b/tests/fixtures/selection_values/block-case-switch.pine new file mode 100644 index 00000000..228e5461 --- /dev/null +++ b/tests/fixtures/selection_values/block-case-switch.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Block case switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: a bare nested SwitchStmt assigns the target recursively: 1/2 when n%3=0, else 3. +n = bar_index +value = switch n % 3 + 0 => + switch n % 2 + 0 => 1.0 + => 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-collection-switch.pine b/tests/fixtures/selection_values/block-collection-switch.pine new file mode 100644 index 00000000..0d0380ae --- /dev/null +++ b/tests/fixtures/selection_values/block-collection-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Collection block switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: nested selection assigns an array ID; reading its element yields 1/2/3. +values = switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => array.from(1.0) + => array.from(2.0) + => array.from(3.0) +plot(array.get(values, 0), "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-comma-if.pine b/tests/fixtures/selection_values/block-comma-if.pine new file mode 100644 index 00000000..e5d30196 --- /dev/null +++ b/tests/fixtures/selection_values/block-comma-if.pine @@ -0,0 +1,19 @@ +//@version=6 +strategy("Block declaration then if", currency = currency.NONE, initial_capital = 1000000) +// Derived: ordered declaration then bare IfStmt yields x+1.125 for x>=0 on positive prices, else -x. +choose(float x) => + switch + x >= 0 => + y = x + 0.125 + if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +x = (bar_index % 2 == 0 ? 1.0 : -1.0) * close +value = choose(x) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-comma-switch.pine b/tests/fixtures/selection_values/block-comma-switch.pine new file mode 100644 index 00000000..33eb6e8e --- /dev/null +++ b/tests/fixtures/selection_values/block-comma-switch.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Block declaration then switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: ordered local declaration then bare switch selects 1/2 for residue zero, else 3. +value = switch bar_index % 3 + 0 => + n = bar_index % 2 + switch n + 0 => 1.0 + => 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-declaration-switch.pine b/tests/fixtures/selection_values/block-declaration-switch.pine new file mode 100644 index 00000000..899a2ed6 --- /dev/null +++ b/tests/fixtures/selection_values/block-declaration-switch.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Block local switch declaration", currency = currency.NONE, initial_capital = 1000000) +// Derived: the local declaration owns a switch RHS; its following read supplies the arm value. +value = switch bar_index % 3 + 0 => + chosen = switch bar_index % 2 + 0 => 1.0 + => 2.0 + chosen + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-default-if.pine b/tests/fixtures/selection_values/block-default-if.pine new file mode 100644 index 00000000..6c30c317 --- /dev/null +++ b/tests/fixtures/selection_values/block-default-if.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Block default if", currency = currency.NONE, initial_capital = 1000000) +// Derived: residue zero yields 1; the bare default IfStmt selects 2 for even n, else 3. +value = switch bar_index % 3 + 0 => 1.0 + => + if bar_index % 2 == 0 + 2.0 + else + 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-default-switch.pine b/tests/fixtures/selection_values/block-default-switch.pine new file mode 100644 index 00000000..0fade85d --- /dev/null +++ b/tests/fixtures/selection_values/block-default-switch.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Block default switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: n%3=0/1 yields 1/2; default recursively selects 3 for even n, 4 for odd n. +n = bar_index +value = switch n % 3 + 0 => 1.0 + 1 => 2.0 + => + switch n % 2 + 0 => 3.0 + => 4.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-function-switch.pine b/tests/fixtures/selection_values/block-function-switch.pine new file mode 100644 index 00000000..79cb2ae9 --- /dev/null +++ b/tests/fixtures/selection_values/block-function-switch.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Function block switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: a callable's bare switch tail writes its return slot through recursive selection. +choose(int n) => + switch n % 3 + 0 => + switch n % 2 + 0 => 1.0 + => 2.0 + => 3.0 +value = choose(bar_index) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-if-with-switch.pine b/tests/fixtures/selection_values/block-if-with-switch.pine new file mode 100644 index 00000000..99989ce6 --- /dev/null +++ b/tests/fixtures/selection_values/block-if-with-switch.pine @@ -0,0 +1,18 @@ +//@version=6 +strategy("Block if containing switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: n%3!=0 yields 4; otherwise odd n yields 3; even n selects 1 for n%5=0, else 2. +n = bar_index +value = switch n % 3 + 0 => + if n % 2 == 0 + switch n % 5 + 0 => 1.0 + => 2.0 + else + 3.0 + => 4.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-method-switch.pine b/tests/fixtures/selection_values/block-method-switch.pine new file mode 100644 index 00000000..954c7d12 --- /dev/null +++ b/tests/fixtures/selection_values/block-method-switch.pine @@ -0,0 +1,17 @@ +//@version=6 +strategy("Method block compound arm", currency = currency.NONE, initial_capital = 1000000) +// Derived: bare nested switch assigns the method return slot with the same 1/2/3 selection. +method choose(int n) => + switch n % 3 + 0 => + switch n % 2 + 0 => 1.0 + => 2.0 + => 3.0 +n = bar_index +value = n.choose() +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-no-default-switch.pine b/tests/fixtures/selection_values/block-no-default-switch.pine new file mode 100644 index 00000000..fc3446fc --- /dev/null +++ b/tests/fixtures/selection_values/block-no-default-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Nested switch without default", currency = currency.NONE, initial_capital = 1000000) +// Derived: n%3=0 takes the inner switch, which yields 1 only for even n, otherwise na. +value = switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => 1.0 + => 3.0 +plot(value, "selected") +plot(na(value) ? 1 : 0, "missing") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-side-effects-switch.pine b/tests/fixtures/selection_values/block-side-effects-switch.pine new file mode 100644 index 00000000..4b9ff930 --- /dev/null +++ b/tests/fixtures/selection_values/block-side-effects-switch.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Nested switch side effects", currency = currency.NONE, initial_capital = 1000000) +// Derived: statement recursion must push exactly once per bar; count equals bar_index+1. +var values = array.new_float() +switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => array.push(values, 1.0) + => array.push(values, 2.0) + => array.push(values, 3.0) +plot(array.size(values), "count") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-tuple-switch.pine b/tests/fixtures/selection_values/block-tuple-switch.pine new file mode 100644 index 00000000..139ebdf0 --- /dev/null +++ b/tests/fixtures/selection_values/block-tuple-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Tuple block switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: recursive tuple selection yields [1,10]/[2,20] when n%3=0, else [3,30]. +[value, other] = switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => [1.0, 10.0] + => [2.0, 20.0] + => [3.0, 30.0] +plot(value + other, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-typed-values.pine b/tests/fixtures/selection_values/block-typed-values.pine new file mode 100644 index 00000000..f7d7da1e --- /dev/null +++ b/tests/fixtures/selection_values/block-typed-values.pine @@ -0,0 +1,21 @@ +//@version=6 +strategy("Typed nested block values", currency = currency.NONE, initial_capital = 1000000) +// Derived: compatible bool and string branches retain their types through nested block selection. +truth = switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => true + => false + => false +text = switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => "one" + => "two" + => "three" +plot(truth ? 1 : 0, "truth") +plot(str.length(text), "length") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-unreached-function.pine b/tests/fixtures/selection_values/block-unreached-function.pine new file mode 100644 index 00000000..740de9f5 --- /dev/null +++ b/tests/fixtures/selection_values/block-unreached-function.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Unreached block compound arm", currency = currency.NONE, initial_capital = 1000000) +// Derived: the callable's structured body is valid independently of its unreachable call. +choose(int n) => + switch n + 0 => + switch n + 0 => 1.0 + => 2.0 + => 3.0 +value = bar_index < 0 ? choose(bar_index) : 4.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/block-var-switch.pine b/tests/fixtures/selection_values/block-var-switch.pine new file mode 100644 index 00000000..a1176583 --- /dev/null +++ b/tests/fixtures/selection_values/block-var-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Persistent block switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: the once latch runs at bar zero, selects 1, and retains it on later bars. +var float value = switch bar_index % 3 + 0 => + switch bar_index % 2 + 0 => 1.0 + => 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/comma-simple-arm.pine b/tests/fixtures/selection_values/comma-simple-arm.pine new file mode 100644 index 00000000..291a2a09 --- /dev/null +++ b/tests/fixtures/selection_values/comma-simple-arm.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Comma simple arm", currency = currency.NONE, initial_capital = 1000000) +// Derived: simple inline declarations and arithmetic tails yield 1/2 without a control wrapper. +value = switch bar_index % 2 + 0 => x = 4.0, x - 3.0 + => y = 6.0, y / 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/delimited-switch.pine b/tests/fixtures/selection_values/delimited-switch.pine new file mode 100644 index 00000000..30906b6a --- /dev/null +++ b/tests/fixtures/selection_values/delimited-switch.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("Delimited switch operand", currency = currency.NONE, initial_capital = 1000000) +// Derived: parentheses suppress block indentation tokens, so parsing fails before the placeholder. +value = math.abs(switch bar_index % 2 + 0 => 1.0 + => 2.0 +) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/enum-title-switch.pine b/tests/fixtures/selection_values/enum-title-switch.pine new file mode 100644 index 00000000..4b32d9ec --- /dev/null +++ b/tests/fixtures/selection_values/enum-title-switch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Switch enum title", currency = currency.NONE, initial_capital = 1000000) +// Derived: the enum analyzer retains only string-literal titles; this default is discarded. +enum Choice + first = switch 0 + 0 => "one" + => "two" +value = Choice.first +plot(str.length(str.tostring(value)), "title length") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/for-end-if.pine b/tests/fixtures/selection_values/for-end-if.pine new file mode 100644 index 00000000..9f1b4b77 --- /dev/null +++ b/tests/fixtures/selection_values/for-end-if.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("If loop end", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: dedented by can complete a header after an if-valued end. +value = 0.0 +for i = 0 to if true + 1 +else + 2 +by 1 + value += 1.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/for-end-switch.pine b/tests/fixtures/selection_values/for-end-switch.pine new file mode 100644 index 00000000..2dc3d500 --- /dev/null +++ b/tests/fixtures/selection_values/for-end-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Compound loop end", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: dedented by can complete a header after a switch-valued end. +value = 0.0 +for i = 0 to switch 0 + 0 => 1 + => 2 +by 1 + value += 1.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/for-literal-bounds.pine b/tests/fixtures/selection_values/for-literal-bounds.pine new file mode 100644 index 00000000..72ac0292 --- /dev/null +++ b/tests/fixtures/selection_values/for-literal-bounds.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Literal loop bounds", currency = currency.NONE, initial_capital = 1000000) +// Derived: inclusive bounds 0 through 1 execute two increments on each bar. +value = 0.0 +for i = 0 to 1 by 1 + value += 1.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/for-start-if.pine b/tests/fixtures/selection_values/for-start-if.pine new file mode 100644 index 00000000..5591b99a --- /dev/null +++ b/tests/fixtures/selection_values/for-start-if.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("If loop start", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: a dedented to token can complete a header after an if-valued start. +value = 0.0 +for i = if true + 0 +else + 1 +to 1 + value += 1.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/for-start-switch.pine b/tests/fixtures/selection_values/for-start-switch.pine new file mode 100644 index 00000000..189ecc4a --- /dev/null +++ b/tests/fixtures/selection_values/for-start-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Compound loop start", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: a dedented to token can complete a header after a switch-valued start. +value = 0.0 +for i = switch 0 + 0 => 0 + => 1 +to 1 + value += 1.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/header-function-if.pine b/tests/fixtures/selection_values/header-function-if.pine new file mode 100644 index 00000000..457dd075 --- /dev/null +++ b/tests/fixtures/selection_values/header-function-if.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Function header if", currency = currency.NONE, initial_capital = 1000000) +// Derived: a function-arrow if is a bare statement; n even yields 1, odd yields 2. +choose(int n) => if n % 2 == 0 + 1.0 +else + 2.0 +value = choose(bar_index) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/header-function-switch.pine b/tests/fixtures/selection_values/header-function-switch.pine new file mode 100644 index 00000000..5c9b6932 --- /dev/null +++ b/tests/fixtures/selection_values/header-function-switch.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("Function header switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: the function-arrow parser keeps a bare SwitchStmt; n even yields 1, odd yields 2. +choose(int n) => switch n % 2 + 0 => 1.0 + => 2.0 +value = choose(bar_index) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/header-method-switch.pine b/tests/fixtures/selection_values/header-method-switch.pine new file mode 100644 index 00000000..2a08cce5 --- /dev/null +++ b/tests/fixtures/selection_values/header-method-switch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Method header switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: method-arrow parsing also preserves the bare switch; even/odd yields 1/2. +method choose(int n) => switch n % 2 + 0 => 1.0 + => 2.0 +n = bar_index +value = n.choose() +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-assignment-switch.pine b/tests/fixtures/selection_values/inline-assignment-switch.pine new file mode 100644 index 00000000..d7e27793 --- /dev/null +++ b/tests/fixtures/selection_values/inline-assignment-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Inline reassignment from switch", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: assignment dispatch owns its switch RHS; this is not ExprStmt(SwitchStmt). +value = 0.0 +switch bar_index % 3 + 0 => value := switch bar_index % 2 + 0 => 1.0 + => 2.0 + => value := 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-case-if.pine b/tests/fixtures/selection_values/inline-case-if.pine new file mode 100644 index 00000000..6562e407 --- /dev/null +++ b/tests/fixtures/selection_values/inline-case-if.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Inline case if", currency = currency.NONE, initial_capital = 1000000) +// Derived: ExprStmt(IfStmt) misses the same scalar-dispatch branches as SwitchStmt. +value = switch bar_index % 3 + 0 => if bar_index % 2 == 0 + 1.0 + else + 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-case-switch.pine b/tests/fixtures/selection_values/inline-case-switch.pine new file mode 100644 index 00000000..0f6b9a0f --- /dev/null +++ b/tests/fixtures/selection_values/inline-case-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Inline case switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: the inline arm wraps SwitchStmt in ExprStmt; value emission falls through. +pick = bar_index % 3 +value = switch pick + 0 => switch bar_index % 2 + 0 => 1.0 + => 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-comma-if.pine b/tests/fixtures/selection_values/inline-comma-if.pine new file mode 100644 index 00000000..400e2732 --- /dev/null +++ b/tests/fixtures/selection_values/inline-comma-if.pine @@ -0,0 +1,17 @@ +//@version=6 +strategy("Inline comma if", currency = currency.NONE, initial_capital = 1000000) +// Derived: the declaration parses as VarDecl, but the comma-tail if becomes ExprStmt. +choose(float x) => + switch + x >= 0 => y = x + 0.125, if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +x = (bar_index % 2 == 0 ? 1.0 : -1.0) * close +value = choose(x) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-comma-switch.pine b/tests/fixtures/selection_values/inline-comma-switch.pine new file mode 100644 index 00000000..876afdd1 --- /dev/null +++ b/tests/fixtures/selection_values/inline-comma-switch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Inline comma switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: inserting a simple declaration before a wrapped switch does not repair dispatch. +value = switch bar_index % 3 + 0 => n = bar_index % 2, switch n + 0 => 1.0 + => 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-declaration-switch.pine b/tests/fixtures/selection_values/inline-declaration-switch.pine new file mode 100644 index 00000000..0bf0943f --- /dev/null +++ b/tests/fixtures/selection_values/inline-declaration-switch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Inline declaration of switch", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: VarDecl owns the switch instead of ExprStmt; the arm reads chosen afterward. +value = switch bar_index % 3 + 0 => chosen = switch bar_index % 2 + 0 => 1.0 + => 2.0 + => 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-default-if.pine b/tests/fixtures/selection_values/inline-default-if.pine new file mode 100644 index 00000000..7b4752ce --- /dev/null +++ b/tests/fixtures/selection_values/inline-default-if.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Inline default if", currency = currency.NONE, initial_capital = 1000000) +// Derived: default-block value emission also sends a wrapped IfStmt to scalar dispatch. +value = switch bar_index % 3 + 0 => 1.0 + => if bar_index % 2 == 0 + 2.0 + else + 3.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-default-switch.pine b/tests/fixtures/selection_values/inline-default-switch.pine new file mode 100644 index 00000000..4822277f --- /dev/null +++ b/tests/fixtures/selection_values/inline-default-switch.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Inline default switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: a default arm uses the same inline ExprStmt wrapper as a case arm. +pick = bar_index % 3 +value = switch pick + 0 => 1.0 + 1 => 2.0 + => switch bar_index % 2 + 0 => 3.0 + => 4.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-if-with-switch.pine b/tests/fixtures/selection_values/inline-if-with-switch.pine new file mode 100644 index 00000000..ead869c7 --- /dev/null +++ b/tests/fixtures/selection_values/inline-if-with-switch.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Inline if containing switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: the outer wrapped if fails before its correctly indented inner switch is emitted. +value = switch bar_index % 3 + 0 => if bar_index % 2 == 0 + switch bar_index % 5 + 0 => 1.0 + => 2.0 + else + 3.0 + => 4.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-method-switch.pine b/tests/fixtures/selection_values/inline-method-switch.pine new file mode 100644 index 00000000..346358f5 --- /dev/null +++ b/tests/fixtures/selection_values/inline-method-switch.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Method inline compound arm", currency = currency.NONE, initial_capital = 1000000) +// Derived: a method return uses the same arm assignment emitter as a function return. +method choose(int n) => + switch n % 3 + 0 => switch n % 2 + 0 => 1.0 + => 2.0 + => 3.0 +n = bar_index +value = n.choose() +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-unreached-function.pine b/tests/fixtures/selection_values/inline-unreached-function.pine new file mode 100644 index 00000000..6651e2b3 --- /dev/null +++ b/tests/fixtures/selection_values/inline-unreached-function.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Unreached inline compound arm", currency = currency.NONE, initial_capital = 1000000) +// Derived: syntax validity must not depend on whether the call's branch ever runs. +choose(int n) => + switch n + 0 => switch n + 0 => 1.0 + => 2.0 + => 3.0 +value = bar_index < 0 ? choose(bar_index) : 4.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/inline-unused-switch.pine b/tests/fixtures/selection_values/inline-unused-switch.pine new file mode 100644 index 00000000..c8e1a65c --- /dev/null +++ b/tests/fixtures/selection_values/inline-unused-switch.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Inline unused switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: statement emission drops a scalar result starting with a comment, hiding the gap. +var values = array.new_float() +switch bar_index % 3 + 0 => switch bar_index % 2 + 0 => array.push(values, 1.0) + => array.push(values, 2.0) + => array.push(values, 3.0) +plot(array.size(values), "count") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/literal-scalar-twins.pine b/tests/fixtures/selection_values/literal-scalar-twins.pine new file mode 100644 index 00000000..e0b87c2b --- /dev/null +++ b/tests/fixtures/selection_values/literal-scalar-twins.pine @@ -0,0 +1,18 @@ +//@version=6 +strategy("Scalar selection references", currency = currency.NONE, initial_capital = 1000000) +// Derived: direct ternaries encode the block probes without passing statement nodes to scalar dispatch. +n = bar_index +caseValue = n % 3 == 0 ? (n % 2 == 0 ? 1.0 : 2.0) : 3.0 +defaultValue = n % 3 == 0 ? 1.0 : n % 3 == 1 ? 2.0 : n % 2 == 0 ? 3.0 : 4.0 +deepValue = n % 3 != 0 ? 4.0 : n % 2 != 0 ? 3.0 : n % 5 == 0 ? 1.0 : 2.0 +x = (n % 2 == 0 ? 1.0 : -1.0) * close +y = x + 0.125 +commaValue = x >= 0 ? (y > 0 ? y + 1.0 : -y + 0.5) : -x +plot(caseValue, "case") +plot(defaultValue, "default") +plot(deepValue, "deep") +plot(commaValue, "comma") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/nested-case.pine b/tests/fixtures/selection_values/nested-case.pine new file mode 100644 index 00000000..27a9af0a --- /dev/null +++ b/tests/fixtures/selection_values/nested-case.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("PineForge F1 syntax probe", overlay = true, initial_capital = 1000000, pyramiding = 2, process_orders_on_close = false) +cycleIndex = int(math.floor(bar_index / 20)) +flip = cycleIndex % 2 == 0 +signalValue = switch cycleIndex % 2 + 0 => switch flip + true => close - open + => open - close + => 0.25 +tradeQty = na(signalValue) ? 0.75 : (signalValue > 0 ? 1.25 : signalValue < 0 ? 2.5 : 3.75) + math.abs(signalValue) / (1.0 + math.abs(signalValue)) +if bar_index % 20 == 5 + strategy.entry("F1", strategy.long, qty = tradeQty) +if bar_index % 20 == 15 + strategy.close("F1", comment = "scheduled") diff --git a/tests/fixtures/selection_values/nested-default.pine b/tests/fixtures/selection_values/nested-default.pine new file mode 100644 index 00000000..76211d79 --- /dev/null +++ b/tests/fixtures/selection_values/nested-default.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("PineForge F1 syntax probe", overlay = true, initial_capital = 1000000, pyramiding = 2, process_orders_on_close = false) +cycleIndex = int(math.floor(bar_index / 20)) +flip = cycleIndex % 2 == 0 +signalValue = switch cycleIndex % 3 + 0 => close - open + 1 => open - close + => switch flip + true => close + => open +tradeQty = na(signalValue) ? 0.75 : (signalValue > 0 ? 1.25 : signalValue < 0 ? 2.5 : 3.75) + math.abs(signalValue) / (1.0 + math.abs(signalValue)) +if bar_index % 20 == 5 + strategy.entry("F1", strategy.long, qty = tradeQty) +if bar_index % 20 == 15 + strategy.close("F1", comment = "scheduled") diff --git a/tests/fixtures/selection_values/price-close.pine b/tests/fixtures/selection_values/price-close.pine new file mode 100644 index 00000000..5fb537d5 --- /dev/null +++ b/tests/fixtures/selection_values/price-close.pine @@ -0,0 +1,21 @@ +//@version=6 +strategy("F1 parity probe", pyramiding = 0, calc_on_every_tick = false, calc_on_order_fills = false, process_orders_on_close = false, default_qty_type = strategy.fixed, default_qty_value = 1, initial_capital = 1000000) +float f1_input_157 = (int(bar_index / 20) % 2 == 0 ? 1.0 : -1.0) * float(close) +f1_sl_157(float x) => + switch + x >= 0 => y = x + 0.125, if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +float f1_leaf_157 = f1_sl_157(f1_input_157) +float f1_signal_157 = float(f1_leaf_157) + (0.11) +float f1_qty_157 = 1.0 + math.abs(nz(f1_signal_157, -0.75)) % 7.0 +var string f1_active_157 = "" +var string f1_value_157 = "" +if bar_index % 20 == 5 and strategy.position_size == 0 + f1_value_157 := str.tostring(f1_signal_157) + f1_active_157 := "f1-" + f1_value_157 + strategy.entry(f1_active_157, strategy.long, qty = f1_qty_157) +if bar_index % 20 == 15 and strategy.position_size != 0 + strategy.close(f1_active_157, comment = f1_value_157) diff --git a/tests/fixtures/selection_values/price-high.pine b/tests/fixtures/selection_values/price-high.pine new file mode 100644 index 00000000..7e409c3a --- /dev/null +++ b/tests/fixtures/selection_values/price-high.pine @@ -0,0 +1,21 @@ +//@version=6 +strategy("F1 parity probe", pyramiding = 0, calc_on_every_tick = false, calc_on_order_fills = false, process_orders_on_close = false, default_qty_type = strategy.fixed, default_qty_value = 1, initial_capital = 1000000) +float f1_input_159 = (int(bar_index / 20) % 2 == 0 ? 1.0 : -1.0) * float(high) +f1_sl_159(float x) => + switch + x >= 0 => y = x + 0.125, if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +float f1_leaf_159 = f1_sl_159(f1_input_159) +float f1_signal_159 = float(f1_leaf_159) + (0.33) +float f1_qty_159 = 1.0 + math.abs(nz(f1_signal_159, -0.75)) % 7.0 +var string f1_active_159 = "" +var string f1_value_159 = "" +if bar_index % 20 == 5 and strategy.position_size == 0 + f1_value_159 := str.tostring(f1_signal_159) + f1_active_159 := "f1-" + f1_value_159 + strategy.entry(f1_active_159, strategy.long, qty = f1_qty_159) +if bar_index % 20 == 15 and strategy.position_size != 0 + strategy.close(f1_active_159, comment = f1_value_159) diff --git a/tests/fixtures/selection_values/price-hl2.pine b/tests/fixtures/selection_values/price-hl2.pine new file mode 100644 index 00000000..72c0e09d --- /dev/null +++ b/tests/fixtures/selection_values/price-hl2.pine @@ -0,0 +1,21 @@ +//@version=6 +strategy("F1 parity probe", pyramiding = 0, calc_on_every_tick = false, calc_on_order_fills = false, process_orders_on_close = false, default_qty_type = strategy.fixed, default_qty_value = 1, initial_capital = 1000000) +float f1_input_161 = (int(bar_index / 20) % 2 == 0 ? 1.0 : -1.0) * float(hl2) +f1_sl_161(float x) => + switch + x >= 0 => y = x + 0.125, if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +float f1_leaf_161 = f1_sl_161(f1_input_161) +float f1_signal_161 = float(f1_leaf_161) + (0.55) +float f1_qty_161 = 1.0 + math.abs(nz(f1_signal_161, -0.75)) % 7.0 +var string f1_active_161 = "" +var string f1_value_161 = "" +if bar_index % 20 == 5 and strategy.position_size == 0 + f1_value_161 := str.tostring(f1_signal_161) + f1_active_161 := "f1-" + f1_value_161 + strategy.entry(f1_active_161, strategy.long, qty = f1_qty_161) +if bar_index % 20 == 15 and strategy.position_size != 0 + strategy.close(f1_active_161, comment = f1_value_161) diff --git a/tests/fixtures/selection_values/price-low.pine b/tests/fixtures/selection_values/price-low.pine new file mode 100644 index 00000000..6d316974 --- /dev/null +++ b/tests/fixtures/selection_values/price-low.pine @@ -0,0 +1,21 @@ +//@version=6 +strategy("F1 parity probe", pyramiding = 0, calc_on_every_tick = false, calc_on_order_fills = false, process_orders_on_close = false, default_qty_type = strategy.fixed, default_qty_value = 1, initial_capital = 1000000) +float f1_input_160 = (int(bar_index / 20) % 2 == 0 ? 1.0 : -1.0) * float(low) +f1_sl_160(float x) => + switch + x >= 0 => y = x + 0.125, if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +float f1_leaf_160 = f1_sl_160(f1_input_160) +float f1_signal_160 = float(f1_leaf_160) + (0.44) +float f1_qty_160 = 1.0 + math.abs(nz(f1_signal_160, -0.75)) % 7.0 +var string f1_active_160 = "" +var string f1_value_160 = "" +if bar_index % 20 == 5 and strategy.position_size == 0 + f1_value_160 := str.tostring(f1_signal_160) + f1_active_160 := "f1-" + f1_value_160 + strategy.entry(f1_active_160, strategy.long, qty = f1_qty_160) +if bar_index % 20 == 15 and strategy.position_size != 0 + strategy.close(f1_active_160, comment = f1_value_160) diff --git a/tests/fixtures/selection_values/price-open.pine b/tests/fixtures/selection_values/price-open.pine new file mode 100644 index 00000000..d7749eaf --- /dev/null +++ b/tests/fixtures/selection_values/price-open.pine @@ -0,0 +1,21 @@ +//@version=6 +strategy("F1 parity probe", pyramiding = 0, calc_on_every_tick = false, calc_on_order_fills = false, process_orders_on_close = false, default_qty_type = strategy.fixed, default_qty_value = 1, initial_capital = 1000000) +float f1_input_158 = (int(bar_index / 20) % 2 == 0 ? 1.0 : -1.0) * float(open) +f1_sl_158(float x) => + switch + x >= 0 => y = x + 0.125, if y > 0 + y + 1.0 + else + -y + 0.5 + => -x +float f1_leaf_158 = f1_sl_158(f1_input_158) +float f1_signal_158 = float(f1_leaf_158) + (0.22) +float f1_qty_158 = 1.0 + math.abs(nz(f1_signal_158, -0.75)) % 7.0 +var string f1_active_158 = "" +var string f1_value_158 = "" +if bar_index % 20 == 5 and strategy.position_size == 0 + f1_value_158 := str.tostring(f1_signal_158) + f1_active_158 := "f1-" + f1_value_158 + strategy.entry(f1_active_158, strategy.long, qty = f1_qty_158) +if bar_index % 20 == 15 and strategy.position_size != 0 + strategy.close(f1_active_158, comment = f1_value_158) diff --git a/tests/fixtures/selection_values/requested-alias-if-block.pine b/tests/fixtures/selection_values/requested-alias-if-block.pine new file mode 100644 index 00000000..2d4b304b --- /dev/null +++ b/tests/fixtures/selection_values/requested-alias-if-block.pine @@ -0,0 +1,15 @@ +//@version=6 +strategy("Requested alias if block arm", currency = currency.NONE, initial_capital = 1000000) +// Block arm (two statements) in the if whose value is the requested expression. +chosen = if close >= open + hi = high + hi +else + lo = low + lo +value = request.security(syminfo.tickerid, timeframe.period, chosen) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/requested-alias-switch-block.pine b/tests/fixtures/selection_values/requested-alias-switch-block.pine new file mode 100644 index 00000000..7a2cb9c2 --- /dev/null +++ b/tests/fixtures/selection_values/requested-alias-switch-block.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Requested alias switch block arm", currency = currency.NONE, initial_capital = 1000000) +// Block arm (two statements) in the switch whose value is the requested expression. +chosen = switch + close >= open => + hi = high + hi + => + lo = low + lo +value = request.security(syminfo.tickerid, timeframe.period, chosen) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/requested-alias-switch-selector-block.pine b/tests/fixtures/selection_values/requested-alias-switch-selector-block.pine new file mode 100644 index 00000000..f0a9345c --- /dev/null +++ b/tests/fixtures/selection_values/requested-alias-switch-selector-block.pine @@ -0,0 +1,17 @@ +//@version=6 +strategy("Requested alias selector switch block arm", currency = currency.NONE, initial_capital = 1000000) +// Block arm declares two locals, uses the first in the second, yields the requested value. +pick_n = input.int(1, "Pick", minval = 1, maxval = 2) +chosen = switch pick_n + 1 => + span_v = high - low + mid_v = low + span_v / 2 + mid_v + => + close +value = request.security(syminfo.tickerid, timeframe.period, chosen) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/requested-timeframe-if-block.pine b/tests/fixtures/selection_values/requested-timeframe-if-block.pine new file mode 100644 index 00000000..e5e00e14 --- /dev/null +++ b/tests/fixtures/selection_values/requested-timeframe-if-block.pine @@ -0,0 +1,16 @@ +//@version=6 +strategy("Requested timeframe if block arm", currency = currency.NONE, initial_capital = 1000000) +// Block arm (two statements) in the if that selects the request timeframe. +pick = input.bool(true, "Choose chart") +tf = if pick + base_tf = timeframe.period + base_tf +else + alt_tf = "1D" + alt_tf +value = request.security(syminfo.tickerid, tf, close) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/requested-timeframe-if-nested-block.pine b/tests/fixtures/selection_values/requested-timeframe-if-nested-block.pine new file mode 100644 index 00000000..d65757da --- /dev/null +++ b/tests/fixtures/selection_values/requested-timeframe-if-nested-block.pine @@ -0,0 +1,18 @@ +//@version=6 +strategy("Requested timeframe if with else-if block arms", currency = currency.NONE, initial_capital = 1000000) +// else-if chain, each arm a two-statement block, plus a plain default arm. +pick_n = input.int(1, "Pick", minval = 1, maxval = 3) +tf = if pick_n == 1 + first_tf = "60" + first_tf +else if pick_n == 2 + second_tf = "240" + second_tf +else + timeframe.period +value = request.security(syminfo.tickerid, tf, close) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/requested-timeframe-switch-block.pine b/tests/fixtures/selection_values/requested-timeframe-switch-block.pine new file mode 100644 index 00000000..a49cc24e --- /dev/null +++ b/tests/fixtures/selection_values/requested-timeframe-switch-block.pine @@ -0,0 +1,17 @@ +//@version=6 +strategy("Requested timeframe switch block arm", currency = currency.NONE, initial_capital = 1000000) +// Block arm (two statements) in the switch that selects the request timeframe. +pick = input.bool(true, "Choose chart") +tf = switch pick + true => + base_tf = timeframe.period + base_tf + => + alt_tf = "1D" + alt_tf +value = request.security(syminfo.tickerid, tf, close) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/requested-timeframe-switch-selector-block.pine b/tests/fixtures/selection_values/requested-timeframe-switch-selector-block.pine new file mode 100644 index 00000000..f38d53af --- /dev/null +++ b/tests/fixtures/selection_values/requested-timeframe-switch-selector-block.pine @@ -0,0 +1,17 @@ +//@version=6 +strategy("Requested timeframe selector switch block arm", currency = currency.NONE, initial_capital = 1000000) +// Block arm computes a local from an input-derived number, then yields the timeframe string. +pick_n = input.int(1, "Pick", minval = 1, maxval = 2) +mult_n = input.int(4, "Multiplier", minval = 1) +tf = switch pick_n + 1 => + base_min = mult_n * 15 + str.tostring(base_min) + => + timeframe.period +value = request.security(syminfo.tickerid, tf, close) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/security-if-alias.pine b/tests/fixtures/selection_values/security-if-alias.pine new file mode 100644 index 00000000..eb3645a7 --- /dev/null +++ b/tests/fixtures/selection_values/security-if-alias.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Requested if alias", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: security's scalar fallback has no ordinary IfStmt expression branch. +chosen = if close >= open + high +else + low +value = request.security(syminfo.tickerid, timeframe.period, chosen) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/security-switch-alias.pine b/tests/fixtures/selection_values/security-switch-alias.pine new file mode 100644 index 00000000..c72bdb55 --- /dev/null +++ b/tests/fixtures/selection_values/security-switch-alias.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("Requested switch alias", currency = currency.NONE, initial_capital = 1000000) +// Derived: security's builder has a dedicated switch-to-ternary path for this global alias. +chosen = switch + close >= open => high + => low +value = request.security(syminfo.tickerid, timeframe.period, chosen) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/security-timeframe-if.pine b/tests/fixtures/selection_values/security-timeframe-if.pine new file mode 100644 index 00000000..16a522a1 --- /dev/null +++ b/tests/fixtures/selection_values/security-timeframe-if.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("Requested timeframe if", currency = currency.NONE, initial_capital = 1000000) +// Boundary probe: registration's switch special case does not itself handle an IfStmt alias. +pick = input.bool(true, "Choose chart") +tf = if pick + timeframe.period +else + "1D" +value = request.security(syminfo.tickerid, tf, close) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/security-timeframe-switch.pine b/tests/fixtures/selection_values/security-timeframe-switch.pine new file mode 100644 index 00000000..78830043 --- /dev/null +++ b/tests/fixtures/selection_values/security-timeframe-switch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Requested timeframe switch", currency = currency.NONE, initial_capital = 1000000) +// Derived: registration explicitly renders a pure switch selecting direct input strings. +pick = input.bool(true, "Choose chart") +tf = switch pick + true => timeframe.period + => "1D" +value = request.security(syminfo.tickerid, tf, close) +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/ternary-if.pine b/tests/fixtures/selection_values/ternary-if.pine new file mode 100644 index 00000000..df86f379 --- /dev/null +++ b/tests/fixtures/selection_values/ternary-if.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("Ternary if operand", currency = currency.NONE, initial_capital = 1000000) +// Derived: an IfStmt false operand also falls through scalar expression dispatch. +value = bar_index % 3 == 0 ? 3.0 : if bar_index % 2 == 0 + 1.0 +else + 2.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/ternary-switch.pine b/tests/fixtures/selection_values/ternary-switch.pine new file mode 100644 index 00000000..17da3997 --- /dev/null +++ b/tests/fixtures/selection_values/ternary-switch.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Ternary switch operand", currency = currency.NONE, initial_capital = 1000000) +// Derived: the unparenthesized false operand parses as SwitchStmt and reaches scalar dispatch. +value = bar_index % 3 == 0 ? 3.0 : switch bar_index % 2 + 0 => 1.0 + => 2.0 +plot(value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/udt-default-if.pine b/tests/fixtures/selection_values/udt-default-if.pine new file mode 100644 index 00000000..126973a4 --- /dev/null +++ b/tests/fixtures/selection_values/udt-default-if.pine @@ -0,0 +1,14 @@ +//@version=6 +strategy("If field default", currency = currency.NONE, initial_capital = 1000000) +// Derived: a field default IfStmt reaches the same omitted-constructor-argument rendering path. +type Sample + float value = if true + 1.0 + else + 2.0 +sample = Sample.new() +plot(sample.value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/udt-default-literal.pine b/tests/fixtures/selection_values/udt-default-literal.pine new file mode 100644 index 00000000..c6ed0cc6 --- /dev/null +++ b/tests/fixtures/selection_values/udt-default-literal.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Literal field default", currency = currency.NONE, initial_capital = 1000000) +// Derived: NumberLiteral is handled by scalar dispatch; omitted field default is always 1. +type Sample + float value = 1.0 +sample = Sample.new() +plot(sample.value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/selection_values/udt-default-switch.pine b/tests/fixtures/selection_values/udt-default-switch.pine new file mode 100644 index 00000000..faa026bf --- /dev/null +++ b/tests/fixtures/selection_values/udt-default-switch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Switch field default", currency = currency.NONE, initial_capital = 1000000) +// Derived: TypeDecl visits its default, then omitted new() arguments render that default as scalar. +type Sample + float value = switch 0 + 0 => 1.0 + => 2.0 +sample = Sample.new() +plot(sample.value, "selected") +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/test_diagnostic_notes.py b/tests/test_diagnostic_notes.py index 33345dd6..6d1f9f7d 100644 --- a/tests/test_diagnostic_notes.py +++ b/tests/test_diagnostic_notes.py @@ -32,7 +32,9 @@ # PF-E2052), the function definition scope errors (PF-E2053 to PF-E2060), the # untyped na declaration error (PF-E2061), the bool and type mismatch errors # (PF-E2066 to PF-E2073) and the strategy() declaration errors (PF-E5080 to -# PF-E5082). +# PF-E5082), the missing value lowering error (PF-E5083) and the requested +# selection errors (PF-E3102 to PF-E3105), including an unmeasured empty +# requested-timeframe arm. CATALOG_ADDITIONS = {"PF-E0038", "PF-E0039", "PF-E0040", "PF-E0041", "PF-E0042", "PF-E2040", "PF-E2041", "PF-E2042", "PF-E2043", "PF-E2044", "PF-E2045", "PF-E2046", "PF-E2047", "PF-E2048", "PF-E2049", @@ -43,7 +45,8 @@ "PF-E2066", "PF-E2067", "PF-E2068", "PF-E2069", "PF-E2070", "PF-E2071", "PF-E2072", "PF-E2073", "PF-E2074", # Positional arguments after keyword arguments. - "PF-E5080", "PF-E5081", "PF-E5082", "PF-W1553"} + "PF-E3102", "PF-E3103", "PF-E3104", "PF-E3105", + "PF-E5080", "PF-E5081", "PF-E5082", "PF-E5083", "PF-W1553"} # The support checker warns, inside a switch arm, what it refuses elsewhere: # PF-W1nnn is PF-E1nnn there. Read off the live catalog, so a twin added later # is covered by the sentence tests below without an edit here. diff --git a/tests/test_e2e_security_helper_switch.py b/tests/test_e2e_security_helper_switch.py index 1fac73f0..9326b21c 100644 --- a/tests/test_e2e_security_helper_switch.py +++ b/tests/test_e2e_security_helper_switch.py @@ -7,8 +7,8 @@ arms: the requested bar computes the arm the selector takes, and no other. TradingView's tape of ``xa_switch_helper`` (``fixtures/xsym_tv``) spells the 60-minute RSI and its SMA on every close; under the override ``MA = EMA`` the -probe trades like its literal twin. A switch with a multi-statement arm is -refused by name. +probe trades like its literal twin. Pure arm locals substitute into the +selected value; stateful calls in substituted initializers remain refused. """ from __future__ import annotations @@ -47,10 +47,13 @@ def test_switch_local_in_helper_replays_tradingview_tape(tmp_path_factory): assert {t["exit_time"]: t["exit_comment"] for t in overridden} == exits["ema"] -def test_switch_with_a_block_arm_is_refused(): +def test_switch_with_stateful_arm_initializer_is_refused(): src = source("xa_switch_helper", XSYM_TV).replace( '"EMA" => ta.ema(r, 5)', '"EMA" =>\n e = ta.ema(r, 5)\n e') with pytest.raises(CompileError) as err: transpile(src) - assert [d.message for d in err.value.diagnostics] == [ - "request.security helper switch arms must each be one expression"] + # A TA initializer has a per-bar state update, so let duplication is unsafe. + assert len(err.value.diagnostics) == 1 + assert err.value.diagnostics[0].code == "PF-E3078" + assert "call without purity proof ta.ema" in err.value.diagnostics[0].message + assert err.value.diagnostics[0].location.line > 0 diff --git a/tests/test_hungpixi_regressions.py b/tests/test_hungpixi_regressions.py index 34373c9e..93cdf224 100644 --- a/tests/test_hungpixi_regressions.py +++ b/tests/test_hungpixi_regressions.py @@ -59,7 +59,7 @@ def test_security_timeframe_switch_without_default_falls_back_to_the_chart(): assert line.count("script_tf_") >= 3 # two selectors and the fallback -def test_security_timeframe_switch_with_multistatement_arm_is_rejected(): +def test_security_timeframe_switch_with_local_arm_matches_expression_arm(): source = ( PRELUDE + "HTF = switch timeframe.period\n" @@ -71,8 +71,9 @@ def test_security_timeframe_switch_with_multistatement_arm_is_rejected(): + "plot(value)\n" ) - with pytest.raises(CompileError, match="switch arms must contain one expression"): - transpile(source) + # Substituting selected leaves the literal arm used by the expression twin. + twin = source.replace(' selected = "5"\n selected\n', ' "5"\n') + assert _registration_line(source) == _registration_line(twin) def test_closed_trade_entry_id_local_is_string_typed(): diff --git a/tests/test_security_selection_blocks.py b/tests/test_security_selection_blocks.py new file mode 100644 index 00000000..457a062b --- /dev/null +++ b/tests/test_security_selection_blocks.py @@ -0,0 +1,419 @@ +"""Pure block values used by request registration and requested evaluators.""" + +from pathlib import Path +import re + +import pytest + +from pineforge_codegen import transpile, transpile_full +from pineforge_codegen.analyzer import Analyzer +from pineforge_codegen.ast_nodes import Identifier, SwitchStmt, VarDecl +from pineforge_codegen.codegen import CodeGen +from pineforge_codegen.errors import CompileError +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.parser import Parser +from tests._compile import compile_cpp, run_emitted_tu + + +FIXTURES = Path(__file__).parent / "fixtures" / "selection_values" +PROBES = ( + "requested-alias-if-block", + "requested-alias-switch-block", + "requested-timeframe-if-block", + "requested-timeframe-switch-block", + "requested-timeframe-switch-selector-block", + "requested-alias-switch-selector-block", + "requested-timeframe-if-nested-block", +) + + +def source(name): + return (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") + + +def registration(cpp): + return cpp.split("register_security_eval(", 1)[1].split( + ", input_tf_", 1)[0] + + +@pytest.mark.parametrize("name", PROBES) +def test_request_block_translates(name): + # Arm-local reads substitute to high/low or timeframe.period/"1D". + assert isinstance(transpile_full(source(name))["cpp"], str) + + +@pytest.mark.parametrize("name", PROBES) +def test_request_block_has_no_empty_expression(name): + # Every selected value has a leaf after substitution, including setup. + assert not re.search(r"(?])=\s*;|\(\s*,|,\s*,|,\s*\)", + transpile(source(name))) + + +@pytest.mark.parametrize("name", PROBES) +def test_request_block_has_no_unknown_placeholder(name): + # A supported arm leaves an expression, never the former sentinel. + assert "/* unknown */" not in transpile(source(name)) + + +@pytest.mark.parametrize("name", PROBES) +@pytest.mark.parametrize("local", ("base_tf", "alt_tf", "hi", "lo", + "base_min", "span_v", "mid_v")) +def test_request_arm_local_is_not_a_member(name, local): + # Isolate the generated class's member section, excluding setup lambdas. + cpp = transpile(source(name)) + class_header = ( + "class GeneratedStrategy : public pineforge::source::PineStrategyHost {\n" + "public:\n" + ) + constructor = " explicit GeneratedStrategy(" + assert class_header in cpp and constructor in cpp + declarations = cpp.split(class_header, 1)[1].split(constructor, 1)[0] + assert not re.search(rf"^ (?:std::string|double|int|Series<[^>]+>) +{local}\b", + declarations, re.MULTILINE) + + +@pytest.mark.parametrize("kind", ("if", "switch")) +@pytest.mark.parametrize("selected", ("true", "false")) +def test_timeframe_block_registers_like_expression_twin(kind, selected): + # Removing the let bindings leaves exactly the taped single-arm selection. + block = source(f"requested-timeframe-{kind}-block").replace( + "input.bool(true,", f"input.bool({selected},") + twin = source(f"security-timeframe-{kind}").replace( + "input.bool(true,", f"input.bool({selected},") + assert registration(transpile(block)) == registration(transpile(twin)) + + +def registered_string(cpp): + expression = registration(cpp).split(", ", 1)[1] + driver = ''' +#include +class FrameProbe : public GeneratedStrategy { +public: + std::string selected() { + script_tf_ = "15"; + return ''' + expression + '''; + } +}; +int main() { + FrameProbe strategy; + std::cout << strategy.selected() << "\\n"; +} +''' + return run_emitted_tu(cpp, driver, opt="-O2", label="registered timeframe").strip() + + +@pytest.mark.parametrize("kind", ("if", "switch")) +@pytest.mark.parametrize("selected,expected", (("true", "15"), ("false", "1D"))) +def test_registered_timeframe_string_matches_both_expression_arms(kind, selected, expected): + block = source(f"requested-timeframe-{kind}-block").replace( + "input.bool(true,", f"input.bool({selected},") + twin = source(f"security-timeframe-{kind}").replace( + "input.bool(true,", f"input.bool({selected},") + # true selects timeframe.period ("15"); false selects "1D", the switch default. + actual = registered_string(transpile(block)) + control = registered_string(transpile(twin)) + assert actual == control + assert actual == expected + + +@pytest.mark.parametrize("selected,expected", ((1, "60"), (2, "15"))) +def test_selector_switch_registered_string_matches_expression_twin(selected, expected): + block = source("requested-timeframe-switch-selector-block").replace( + "input.int(1,", f"input.int({selected},") + twin = block.replace(" base_min = mult_n * 15\n" + " str.tostring(base_min)", + " str.tostring(mult_n * 15)") + # The selected arm computes 4*15; selector 2 reaches the chart-timeframe default. + actual = registered_string(transpile(block)) + control = registered_string(transpile(twin)) + assert actual == control + assert actual == expected + + +@pytest.mark.parametrize("selected,expected", ((1, "60"), (2, "240"), (3, "15"))) +def test_else_if_registered_string_matches_expression_twin(selected, expected): + block = source("requested-timeframe-if-nested-block").replace( + "input.int(1,", f"input.int({selected},") + twin = block.replace(' first_tf = "60"\n first_tf', ' "60"').replace( + ' second_tf = "240"\n second_tf', ' "240"') + # The two conditions choose "60"/"240"; selector 3 reaches timeframe.period. + actual = registered_string(transpile(block)) + control = registered_string(transpile(twin)) + assert actual == control + assert actual == expected + + +@pytest.mark.parametrize("name", PROBES) +def test_request_block_compiles(name): + # Both registration and evaluator consume complete scalar expressions. + compile_cpp(transpile(source(name))) + + +@pytest.mark.parametrize("name", PROBES) +def test_request_block_construction_only_exposes_compile_errors(name): + # Construction resolves timeframes before ordinary statement state exists. + pine = source(name) + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + context = Analyzer(ast).analyze() + try: + generator = CodeGen(context) + except CompileError: + return + assert isinstance(generator, CodeGen) + + +def test_registration_never_emits_statements(monkeypatch): + def unexpected(*args, **kwargs): + pytest.fail("registration attempted ordinary statement emission") + + # Block arms normalize before construction can reach statement-only state. + monkeypatch.setattr(CodeGen, "_visit_if_switch_expr", unexpected) + for name in ("requested-timeframe-if-block", "requested-timeframe-switch-block"): + pine = source(name) + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + CodeGen(Analyzer(ast).analyze()) + + +def test_expression_arm_timeframe_if_registers_a_typed_lambda(monkeypatch): + original = CodeGen._visit_if_switch_expr + visits = [] + + def record(self, *args, **kwargs): + visits.append(args[0]) + return original(self, *args, **kwargs) + + monkeypatch.setattr(CodeGen, "_visit_if_switch_expr", record) + pine = source("security-timeframe-if") + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + # Expression-arm ifs now register a typed lambda; main emitted an empty expression. + CodeGen(Analyzer(ast).analyze()) + assert visits + + +def test_expression_arm_timeframe_if_registration_has_typed_assignments(): + # Slot 0 and typed assignments repair main's empty registration expression. + expected = ( + '0, ([&]() -> std::string {\n' + ' std::string __selection_value_0 = na();\n' + ' if (get_input_bool("Choose chart", true)) {\n' + ' __selection_value_0 = script_tf_;\n' + ' } else {\n' + ' __selection_value_0 = std::string("1D");\n' + ' }\n' + ' return __selection_value_0;\n' + '}())' + ) + assert registration(transpile(source("security-timeframe-if"))) == expected + + +def test_scalar_timeframe_switch_registration_has_the_original_spelling(): + # Preservation pin: the default-first renderer builds the compact conditional. + expected = ( + '0, ((((get_input_bool("Choose chart", true)) == (true))) ? ' + '(script_tf_) : (std::string("1D")))' + ) + assert registration(transpile(source("security-timeframe-switch"))) == expected + + +def test_scalar_timeframe_switch_is_dispatched_before_substitution(monkeypatch): + original = CodeGen._security_tf_switch_runtime_expr + seen = [] + + def record(self, node, resolving): + # The raw selector is the input alias; substitution would replace it by a call. + assert isinstance(node.expr, Identifier) + assert node.expr.name == "pick" + seen.append(node) + return original(self, node, resolving) + + pine = source("security-timeframe-switch") + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + switch = next(stmt.value for stmt in ast.body + if isinstance(stmt, VarDecl) and isinstance(stmt.value, SwitchStmt)) + monkeypatch.setattr(CodeGen, "_security_tf_switch_runtime_expr", record) + CodeGen(Analyzer(ast).analyze()) + assert seen + assert all(node is switch for node in seen) + + +@pytest.mark.parametrize("position", ("timeframe-if", "timeframe-switch", + "value-if", "value-switch")) +@pytest.mark.parametrize("initializer", ("close * 2", "ta.sma(close, 3)")) +def test_block_local_history_is_a_located_gap(position, initializer): + setup = position.startswith("timeframe") + result = "str.tostring(prior_v[1])" if setup else "prior_v[1]" + fallback = '"240"' if setup else "240" + if position.endswith("switch"): + selection = (f"switch pick\n true =>\n" + f" prior_v = {initializer}\n {result}\n" + f" => {fallback}\n") + else: + selection = (f"if pick\n prior_v = {initializer}\n {result}\n" + f"else\n {fallback}\n") + request = ("request.security(syminfo.tickerid, chosen, close)" if setup else + "request.security(syminfo.tickerid, timeframe.period, chosen)") + pine = ('//@version=6\nstrategy("Local history")\npick = input.bool(true)\n' + "chosen = " + selection + "value = " + request + "\nplot(value)\n") + # prior_v[1] needs the local's previous-bar slot, never a substituted current value. + with pytest.raises(CompileError) as caught: + transpile_full(pine) + assert len(caught.value.diagnostics) == 1 + diagnostic = caught.value.diagnostics[0] + assert diagnostic.code == "PF-E3085" + assert "history of a block-local" in diagnostic.message + history_line = next(i for i, line in enumerate(pine.splitlines(), 1) + if "prior_v[1]" in line) + assert diagnostic.location.line == history_line + assert diagnostic.location.col == pine.splitlines()[history_line - 1].index("[") + 1 + + +@pytest.mark.parametrize("position", ("timeframe", "value")) +@pytest.mark.parametrize("selected,expected", (("a", "60"), ("b", "240"))) +def test_let_bound_string_selector_has_string_comparisons(position, selected, expected): + setup = position == "timeframe" + first, second = ('"60"', '"240"') if setup else ("60", "240") + # The outer let is substituted into the nested switch's selector. + selection = ( + f'if pick\n choice_v = "{selected}"\n switch choice_v\n' + f' "a" => {first}\n => {second}\nelse\n {second}\n' + if setup else + f'switch pick\n true =>\n choice_v = "{selected}"\n' + f' switch choice_v\n "a" => {first}\n' + f' => {second}\n => {second}\n' + ) + request = ("request.security(syminfo.tickerid, chosen, close)" if setup else + "request.security(syminfo.tickerid, timeframe.period, chosen)") + pine = ('//@version=6\nstrategy("String selection")\npick = input.bool(true)\n' + "chosen = " + selection + "value = " + request + "\nplot(value)\n") + cpp = transpile(pine) + # String inference forces a std::string selector even when its value is a literal. + if setup: + assert re.search(r'std::string __switch_val_\d+ = ', registration(cpp)) + assert registered_string(cpp) == expected + else: + evaluator = cpp.split("void _eval_security_0(", 1)[1].split( + "void evaluate_security(", 1)[0] + assert f'std::string(std::string("{selected}"))' in evaluator + assert 'std::string("a")' in evaluator + assert re.search(r"\?\s*\(60\)\s*:\s*\(240\)", evaluator) + compile_cpp(cpp) + + +UNSAFE_ARMS = ( + # Calls are located at '('; history reads at '['; statements at their first word. + (' pick := false\n "60"\n', 5, 5, "outer assignment"), + (' candidate = "1D"\n for idx = 0 to 1\n candidate := "60"\n' + ' candidate\n', 6, 5, "ForStmt"), + (' candidate = str.tostring(math.random())\n candidate\n', + 5, 41, "math.random"), + (' candidate = str.tostring(close[1])\n candidate\n', 5, 35, "history"), + (' candidate = str.tostring(bar_index)\n candidate\n', 5, 30, "bar_index"), + (' candidate = missing_value\n candidate\n', 5, 17, "missing_value"), +) + + +@pytest.mark.parametrize("arm,line,col,construct", UNSAFE_ARMS) +def test_registration_unsafe_arm_is_a_located_refusal(arm, line, col, construct): + pine = ('//@version=6\nstrategy("Setup selection")\npick = input.bool(true)\n' + 'tf = if pick\n' + arm + 'else\n "1D"\n' + 'value = request.security(syminfo.tickerid, tf, close)\nplot(value)\n') + # The first unsafe node, rather than a constructor traceback, owns the error. + with pytest.raises(CompileError) as caught: + transpile_full(pine) + assert len(caught.value.diagnostics) == 1 + diagnostic = caught.value.diagnostics[0] + assert diagnostic.code != "PF-E0000" + assert diagnostic.code.startswith("PF-E") + assert diagnostic.location.line == line + assert diagnostic.location.col == col + assert construct in diagnostic.message + + +ARM_KINDS = { + "declaration": 'candidate = 60\n{result}', + "nested": 'candidate = 60\nif pick\n {result}\nelse\n {fallback}', + "tuple": '[first_v, second_v] = pair()\n{tuple_result}', + "reassignment": 'candidate = 30\ncandidate := candidate * 2\n{result}', + "impure": 'candidate = math.random(1, 60)\n{result}', + "history": 'candidate = close[1]\n{result}', + "loop": 'candidate = 60\nfor idx = 0 to 1\n candidate += idx\n{result}', + "na": 'candidate = 60\nna', + "mixed": 'candidate = 60\n{result}', +} + + +def variant(position, arm_kind): + setup = position.startswith("timeframe") + fallback = '"240"' if setup else "240" + arm = ARM_KINDS[arm_kind].format( + result="str.tostring(candidate)" if setup else "candidate", + tuple_result="str.tostring(first_v + second_v)" if setup else "first_v + second_v", + fallback=fallback, + ) + is_switch = position.endswith("switch") + indent = " " if is_switch else " " + body = "\n".join(indent + line for line in arm.splitlines()) + "\n" + fallback_body = ( + f"{indent}{fallback}\n" if arm_kind == "mixed" else + f"{indent}fallback_v = {fallback}\n{indent}fallback_v\n" + ) + selection = ("switch pick\n true =>\n" + body + " =>\n" + fallback_body + if is_switch else "if pick\n" + body + "else\n" + fallback_body) + request = ("request.security(syminfo.tickerid, chosen, close)" + if setup else + "request.security(syminfo.tickerid, timeframe.period, chosen)") + return ('//@version=6\nstrategy("Selection combinations")\n' + 'pair() => [30, 30]\npick = input.bool(true)\nchosen = ' + + selection + "value = " + request + "\nplot(value)\n") + + +@pytest.mark.parametrize("position", ("timeframe-if", "timeframe-switch", + "value-if", "value-switch")) +@pytest.mark.parametrize("arm_kind", ARM_KINDS) +def test_selection_class_has_only_translation_or_one_located_error(position, arm_kind): + """Real CodeGen: translate without empty values/placeholders or one located PF-E; + a Python traceback is never an allowed result. + """ + # Every arm has a final value; only its leaves/statements can prevent lowering. + try: + cpp = transpile_full(variant(position, arm_kind))["cpp"] + except CompileError as error: + assert len(error.diagnostics) == 1 + diagnostic = error.diagnostics[0] + assert diagnostic.code.startswith("PF-E") + assert diagnostic.code != "PF-E0000" + assert diagnostic.location.line > 0 + assert diagnostic.location.col > 0 + else: + assert "/* unknown */" not in cpp + assert not re.search(r"=\s*;|,\s*,|,\s*\)", cpp) + + +@pytest.mark.parametrize("position", ("timeframe-if", "timeframe-switch", + "value-if", "value-switch")) +@pytest.mark.parametrize("arm_kind", ("declaration", "nested", "tuple", + "reassignment", "mixed")) +def test_pure_bindings_translate_in_both_request_positions(position, arm_kind): + # Pure substitution leaves 60 (formatted at setup); value-if is a preservation pin. + assert isinstance(transpile_full(variant(position, arm_kind))["cpp"], str) + + +def test_setup_substitution_preserves_earlier_binding_when_shadowed(): + pine = ('//@version=6\nstrategy("Local scope")\n' + 'candidate = input.int(60)\npick = input.bool(true)\n' + 'tf = if pick\n saved_v = candidate\n candidate = 240\n' + ' str.tostring(saved_v)\nelse\n "240"\n' + 'value = request.security(syminfo.tickerid, tf, close)\nplot(value)\n') + # saved_v captures the input before candidate becomes the arm-local constant. + assert "get_input_int(" in registration(transpile(pine)) + + +def test_expression_only_setup_keeps_its_existing_bar_condition(): + pine = ('//@version=6\nstrategy("Expression condition")\n' + 'tf = if close > open\n "60"\nelse\n "240"\n' + 'value = request.security(syminfo.tickerid, tf, close)\nplot(value)\n') + # Acceptance pin: TradingView accepts a series condition choosing a request + # timeframe (its tape has 26 entries, first quantities all 1), and main's + # registration reads the chart-field condition; the quantity is constant, + # so the tape proves acceptance, not the value. + assert "current_bar_.close" in registration(transpile(pine)) diff --git a/tests/test_security_selection_current_bar.py b/tests/test_security_selection_current_bar.py new file mode 100644 index 00000000..eb0c57d6 --- /dev/null +++ b/tests/test_security_selection_current_bar.py @@ -0,0 +1,239 @@ +"""Requested selections must use the current requested bar's values.""" + +import math +import re + +import pytest + +from pineforge_codegen import transpile +from tests._compile import run_emitted_tu + + +BLOCK_IF = """chosen = if bar_index % 2 == 0 + step_v = bar_index * 3 + step_v + 1 +else + bar_index * 2 +""" +SINGLE_IF = """chosen = if bar_index % 2 == 0 + bar_index * 3 + 1 +else + bar_index * 2 +""" +REQUEST = ( + "got_v = request.security(syminfo.tickerid, timeframe.period, chosen)\n" +) + + +def alternating_value(bar): + return 3 * bar + 1 if bar % 2 == 0 else 2 * bar + + +ROWS = ( + # The even arm yields 3*b+1; the odd arm yields 2*b. Expected red. + ("block-if", BLOCK_IF + REQUEST, alternating_value), + # The ternary has the same two formulas. Preservation control. + ("ternary", "chosen = bar_index % 2 == 0 ? bar_index * 3 + 1 : bar_index * 2\n" + + REQUEST, alternating_value), + # Removing the local leaves the same if value; its chart fallback also lags. + ("expression-if", SINGLE_IF + REQUEST, alternating_value), + # A direct payload yields 3*b+1 on both parities. Preservation control. + ("expression", "got_v = request.security(syminfo.tickerid, timeframe.period, " + "bar_index * 3 + 1)\n", lambda bar: 3 * bar + 1), + # Without a request the declaration computes the current chart value. + ("plain-block-if", BLOCK_IF + "got_v = chosen\n", alternating_value), + # Selector 1 yields (3*b)+2 through two locals. Preservation control. + ("block-switch", 'pick_n = input.int(1, "Pick", minval = 1, maxval = 2)\n' + "chosen = switch pick_n\n" + " 1 =>\n" + " base_v = bar_index * 3\n" + " shifted_v = base_v + 2\n" + " shifted_v\n" + " => bar_index\n" + REQUEST, lambda bar: 3 * bar + 2), + # b adds one to a's selected current value, through a second global. + ("global-chain", BLOCK_IF.replace("chosen =", "first_v =") + + "chosen = first_v + 1\n" + REQUEST, + lambda bar: alternating_value(bar) + 1), + # A nested tail still selects 3*b+1 on even bars and 2*b otherwise. + ("nested-if", """chosen = if bar_index % 2 == 0 + step_v = bar_index * 3 + if step_v >= 0 + step_v + 1 + else + 0 +else + bar_index * 2 +""" + REQUEST, alternating_value), + # A helper's terminal if has exactly the same selected value. + ("helper-if", """selected_v() => + if bar_index % 2 == 0 + step_v = bar_index * 3 + step_v + 1 + else + bar_index * 2 +chosen = selected_v() +""" + REQUEST, alternating_value), + # The selected helper computes 3*b+1 inside its arm; the other arm is 2*b. + ("helper-arm", """shifted_v(int source_v) => + scaled_v = source_v * 3 + scaled_v + 1 +chosen = if bar_index % 2 == 0 + shifted_v(bar_index) +else + bar_index * 2 +""" + REQUEST, alternating_value), +) + + +@pytest.mark.parametrize("name,declarations,value_at", ROWS, + ids=[row[0] for row in ROWS]) +def test_requested_selection_uses_current_bar(name, declarations, value_at): + pine = ( + '//@version=6\nstrategy("Current selection", currency = currency.NONE, ' + "initial_capital = 1000000)\n" + + declarations + + "qty_v = 1 + got_v % 5\n" + "if bar_index > 6 and bar_index % 7 == 3\n" + ' strategy.entry("L", strategy.long, qty = qty_v)\n' + "if bar_index > 6 and bar_index % 7 == 5\n" + ' strategy.close("L")\n' + ) + driver = r''' +#include +int main() { + Bar bars[60]; + for (int i = 0; i < 60; ++i) + bars[i] = Bar{100.0, 101.0, 99.0, 100.0, 1.0, + 1743465600000LL + i * 900000LL}; + for (int count = 1; count <= 60; ++count) { + GeneratedStrategy strategy; + strategy.run(bars, count, "15", "15"); + if (!strategy.last_error().empty()) return 1; + std::cout << strategy.got_v << " " << strategy.qty_v << "\n"; + } +} +''' + actual = [ + tuple(map(float, line.split())) + for line in run_emitted_tu( + transpile(pine), driver, opt="-O2", label=name + ).splitlines() + ] + # Check every bar, including the quantities used on entry bars 10+7*k. + expected = [(value_at(bar), 1 + value_at(bar) % 5) for bar in range(60)] + assert actual == expected + if value_at is alternating_value: + # Substituting 10,17,24,31,38,45,52 into 1+chosen%5 gives the tape. + assert [actual[bar][1] for bar in range(10, 53, 7)] == [2, 5, 4, 3, 1, 1, 3] + + +@pytest.mark.parametrize("payload,expected", ( + # The first requested bar selects these values; the chart's index is still -1. + # OHLC/time leaves already map to bar; their enclosing condition was wrong. + ("close", 203.0), + ("high - low", 17.0), + ("time", 900000.0), + # Requested 60-minute bars expose their own timeframe, not the chart's 15. + ("timeframe.multiplier", 60.0), +)) +def test_if_arm_reads_the_requested_context(payload, expected): + pine = ( + '//@version=6\nstrategy("Requested leaves")\n' + "chosen = if bar_index == 0\n" + f" arm_v = {payload}\n" + " arm_v\n" + "else\n" + " 0\n" + 'got_v = request.security(syminfo.tickerid, "60", chosen)\n' + ) + # Exercise the actual evaluator with a bar distinct from the chart's default bar. + driver = r''' +#include +#include +int main() { + GeneratedStrategy strategy; + Bar requested{200.0, 211.0, 194.0, 203.0, 1.0, 900000LL}; + strategy.evaluate_security(0, requested, true); + std::cout << std::setprecision(17) << strategy._req_sec_0 << "\n"; +} +''' + assert float(run_emitted_tu( + transpile(pine), driver, opt="-O2", label="arm context")) == expected + + +@pytest.mark.parametrize("condition", ("bar_index >= 0", "bar_index % 2 == 0")) +def test_if_arm_sma_uses_requested_closes(condition): + pine = '''//@version=6 +strategy("Requested average") +chosen = if bar_index >= 0 + averaged_v = ta.sma(close, 3) + averaged_v +else + 0.0 +got_v = request.security(syminfo.tickerid, timeframe.period, chosen) +''' + pine = pine.replace("bar_index >= 0", condition) + driver = r''' +#include +#include +int main() { + Bar bars[8]; + for (int i = 0; i < 8; ++i) + bars[i] = Bar{100.0 + i, 102.0 + i, 99.0 + i, 101.0 + i, 1.0, + 1743465600000LL + i * 900000LL}; + std::cout << std::setprecision(17); + for (int count = 1; count <= 8; ++count) { + GeneratedStrategy strategy; + strategy.run(bars, count, "15", "15"); + if (!strategy.last_error().empty()) return 1; + std::cout << strategy.got_v << "\n"; + } +} +''' + actual = list(map(float, run_emitted_tu( + transpile(pine), driver, opt="-O2", label="arm sma").splitlines())) + assert len(actual) == 8 + # The average consumes only taken-arm closes: all bars, or even bars alone. + samples = [] + for bar, observed in enumerate(actual): + taken = condition == "bar_index >= 0" or bar % 2 == 0 + if taken: + samples.append(101.0 + bar) + expected = (sum(samples[-3:]) / 3 if len(samples) >= 3 else math.nan) if taken else 0.0 + assert math.isnan(observed) if math.isnan(expected) else observed == expected + + +def test_request_arm_local_does_not_write_chart_storage(): + pine = ( + '//@version=6\nstrategy("Separate arm storage")\n' + + BLOCK_IF + REQUEST + + "float chart_v = na\n" + "if bar_index >= 0\n" + " step_v = bar_index + 100\n" + " chart_v := step_v\n" + ) + cpp = transpile(pine) + evaluator = cpp.split("void _eval_security_0(", 1)[1].split( + "void evaluate_security(", 1)[0] + # A bare assignment would write a chart member; a typed local shadows it. + assert not re.search(r"(?m)^\s*step_v\s*=", evaluator) + driver = r''' +#include +int main() { + Bar bars[4]; + for (int i = 0; i < 4; ++i) + bars[i] = Bar{100.0, 101.0, 99.0, 100.0, 1.0, + 1743465600000LL + i * 900000LL}; + for (int count = 1; count <= 4; ++count) { + GeneratedStrategy strategy; + strategy.run(bars, count, "15", "15"); + if (!strategy.last_error().empty()) return 1; + std::cout << strategy.chart_v << "\n"; + } +} +''' + # The later chart block binds its own step_v to b+100, independently of the arm. + assert list(map(float, run_emitted_tu( + cpp, driver, opt="-O2", label="chart block").splitlines())) == [ + 100.0, 101.0, 102.0, 103.0, + ] diff --git a/tests/test_security_selection_empty_timeframe_arm.py b/tests/test_security_selection_empty_timeframe_arm.py new file mode 100644 index 00000000..0dfd5341 --- /dev/null +++ b/tests/test_security_selection_empty_timeframe_arm.py @@ -0,0 +1,158 @@ +"""Known empty timeframe selection arms stop translation pending value evidence.""" + +import json +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile, transpile_full +from pineforge_codegen.ast_nodes import StringLiteral +from pineforge_codegen.errors import CompileError, Level, Phase +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.limits import iter_ast_nodes +from pineforge_codegen.parser import Parser + + +SOURCES = ( + ('//@version=6\nstrategy("Conditional request target")\n' + 'empty = input.bool(true, "Empty")\n' + 'tf = if empty\n' + ' ""\n' + 'else\n' + ' "240"\n' + 'value = request.security(syminfo.tickerid, tf, ta.sma(close, 3))\n' + 'plot(value)\n', 5, 5), + ('//@version=6\nstrategy("Block switch request target")\n' + 'empty = input.bool(true, "Empty")\n' + 'tf = switch empty\n' + ' true =>\n' + ' selectedTarget = ""\n' + ' selectedTarget\n' + ' =>\n' + ' selectedTarget = "240"\n' + ' selectedTarget\n' + 'value = request.security(syminfo.tickerid, tf, ta.sma(close, 3))\n' + 'plot(value)\n', 6, 26), + ('//@version=6\nstrategy("String selector block request target")\n' + 'routeChoice = input.string("chart", "Route", options = ["chart", "explicit"])\n' + 'chosenInterval = switch routeChoice\n' + ' "chart" =>\n' + ' armInterval = ""\n' + ' armInterval\n' + ' =>\n' + ' armInterval = "240"\n' + ' armInterval\n' + 'observedMean = request.security(syminfo.tickerid, chosenInterval, ta.sma(close, 3))\n' + 'plot(observedMean)\n', 6, 23), +) + + +@pytest.fixture +def protocol(): + root = Path(__file__).resolve().parent.parent + namespace = {} + glue = (root / "gate" / "glue.py").read_text(encoding="utf-8") + exec(compile(glue.replace('"/codegen"', repr(str(root))), "glue.py", "exec"), + namespace) + return lambda source: json.loads(namespace["transpile_json"](source)) + + +@pytest.mark.parametrize("pine,line,col", SOURCES) +def test_empty_timeframe_selection_arm_refuses_at_literal(protocol, pine, line, col): + # Red before the guard: an empty selected timeframe has no measured TV value. + # Columns count indentation plus the local declaration prefix, if present. + ast = Parser(Lexer(pine).tokenize(), source=pine, + filename="empty_timeframe.pine").parse() + literal, = (node for node, _ in iter_ast_nodes(ast) + if isinstance(node, StringLiteral) and node.value == "") + assert (literal.loc.line, literal.loc.col) == (line, col) + for translate in (transpile, transpile_full): + with pytest.raises(CompileError) as caught: + translate(pine, filename="empty_timeframe.pine") + diagnostic, = caught.value.diagnostics + assert diagnostic.code == "PF-E3105" + assert diagnostic.level is Level.ERROR + assert diagnostic.phase is Phase.CODEGEN + assert diagnostic.location.file == "empty_timeframe.pine" + assert (diagnostic.location.line, diagnostic.location.col) == (line, col) + assert diagnostic.location == literal.loc + envelope = protocol(pine) + assert not envelope["ok"] and "cpp" not in envelope + diagnostic, = envelope["diagnostics"] + assert (diagnostic["code"], diagnostic["line"], diagnostic["col"]) == ( + "PF-E3105", line, col, + ) + + +@pytest.mark.parametrize("pine,line,col", SOURCES) +def test_nonempty_timeframe_selection_arm_keeps_translating(pine, line, col): + # Preservation control: replacing only the empty arm by "60" avoids the guard. + assert isinstance(transpile(pine.replace('""', '"60"')), str) + + +def test_empty_scalar_ternary_timeframe_keeps_translating(): + pine = ('//@version=6\nstrategy("Scalar ternary target")\n' + 'pick = input.bool(true, "Pick")\n' + 'tf = pick ? "" : "240"\n' + 'value = request.security(syminfo.tickerid, tf, ta.sma(close, 3))\n' + 'plot(value)\n') + # Preservation control: a scalar ternary never enters an if/switch arm. + assert isinstance(transpile(pine), str) + + +@pytest.mark.parametrize("selection", ( + 'if pick\n ""\nelse\n "240"', + 'switch pick\n true => ""\n => "240"', + 'if pick\n "60"\nelse\n ""', + 'switch pick\n true => "60"\n => ""', + 'if pick\n localTf = ""\n localTf\nelse\n "240"', + 'if pick\n string localTf = ""\n localTf\nelse\n "240"', + 'if pick\n localTf = ""\n switch pick\n true => localTf\n => "240"\nelse\n "60"', +)) +def test_bound_and_aliased_selections_share_empty_arm_guard(selection): + setup = '//@version=6\nstrategy("Selection aliases")\npick = input.bool(true)\n' + for prefix, timeframe in ( + (f"chosen = {selection}\n", "chosen"), + (f"chosen = {selection}\naliasTf = chosen\n", "aliasTf"), + ): + pine = (setup + prefix + + f'value = request.security(syminfo.tickerid, {timeframe}, close)\n' + 'plot(value)\n') + # Red before the guard: binding aliases and local lets keep the empty value. + with pytest.raises(CompileError) as caught: + transpile(pine) + assert caught.value.diagnostics[0].code == "PF-E3105" + + +def test_runtime_timeframe_arm_is_not_assumed_empty(): + pine = ('//@version=6\nstrategy("Runtime timeframe arm")\n' + 'pick = input.bool(true)\nrequestedTf = input.timeframe("")\n' + 'tf = if pick\n chosenTf = requestedTf\n chosenTf\n' + 'else\n "240"\n' + 'value = request.security(syminfo.tickerid, tf, close)\nplot(value)\n') + # Preservation control: an input read is not a proven empty constant. + assert isinstance(transpile(pine), str) + + +def test_nonempty_typed_local_timeframe_arm_keeps_translating(): + pine = ('//@version=6\nstrategy("Typed timeframe arm")\npick = input.bool(true)\n' + 'tf = if pick\n string localTf = "60"\n localTf\n' + 'else\n "240"\n' + 'value = request.security(syminfo.tickerid, tf, close)\nplot(value)\n') + # Preservation control: a string identity cast does not make "60" empty. + assert isinstance(transpile(pine), str) + + +@pytest.mark.parametrize("selection", ( + 'if pick\n inheritedTf\nelse\n "240"', + 'switch pick\n true => inheritedTf\n => "240"', +)) +def test_constant_timeframe_arm_is_followed(selection): + pine = ('//@version=6\nstrategy("Constant timeframe arm")\n' + 'pick = input.bool(true)\nbaseTf = ""\ninheritedTf = baseTf\n' + f'tf = {selection}\n' + 'value = request.security(syminfo.tickerid, tf, close)\nplot(value)\n') + # Red before the guard: constant aliases still select the empty timeframe. + with pytest.raises(CompileError) as caught: + transpile(pine) + assert caught.value.diagnostics[0].code == "PF-E3105" diff --git a/tests/test_security_selection_outer_assign.py b/tests/test_security_selection_outer_assign.py new file mode 100644 index 00000000..d6493f36 --- /dev/null +++ b/tests/test_security_selection_outer_assign.py @@ -0,0 +1,303 @@ +"""Requested outer stores and terminal results keep their value contracts.""" + +import math +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests._compile import run_emitted_tu + + +HEADER = ( + '//@version=6\nstrategy("Requested values", currency = currency.NONE, ' + "initial_capital = 1000000)\n" +) +SCHEDULE = ( + "if bar_index > 6 and bar_index % 7 == 3\n" + ' strategy.entry("L", strategy.long, qty = qty_v)\n' + "if bar_index > 6 and bar_index % 7 == 5\n" + ' strategy.close("L")\n' +) +REQUEST = "request.security(syminfo.tickerid, timeframe.period, chosen)" +ENTRY_BARS = range(10, 53, 7) +DRIVER = r''' +#include +#include +int main() { + Bar bars[60]; + for (int i = 0; i < 60; ++i) + bars[i] = Bar{100.0, 101.0, 99.0, 100.0, 1.0, + 1743465600000LL + i * 900000LL}; + std::cout << std::setprecision(17); + for (int count = 1; count <= 60; ++count) { + GeneratedStrategy strategy; + strategy.run(bars, count, "15", "15"); + if (!strategy.last_error().empty()) return 1; + std::cout << strategy.got_v << " " << strategy.qty_v << "\n"; + } +} +''' + + +def outer_source(persistent, *, requested=True, name="balance_v", op=":=", switch=False, + else_only=False): + prefix = (persistent + " ") if isinstance(persistent, str) else ("var " if persistent else "") + store = f"{name} := {name} + 1" if op == ":=" else f"{name} {op} 1" + if switch: + selection = ( + "chosen = switch bar_index % 2\n" + + (" 0 => bar_index * 3\n =>\n" + f" {store}\n bar_index * 2\n" if else_only else + f" 0 =>\n {store}\n bar_index * 3\n" + " => bar_index * 2\n") + ) + else: + selection = ( + "chosen = if bar_index % 2 == 0\n" + + (" bar_index * 3\nelse\n" + f" {store}\n bar_index * 2\n" if else_only else + f" {store}\n bar_index * 3\nelse\n bar_index * 2\n") + ) + return ( + HEADER + + f"{prefix}float {name} = 0.0\n" + + selection + + f"got_v = {REQUEST if requested else 'chosen'}\n" + f"qty_v = 1 + (got_v + int({name})) % 5\n" + + SCHEDULE + ) + + +def outer_values(bar, persistent): + # The chart increments on even bars; var retains prior increments, + # while a plain declaration resets to zero before each chart evaluation. + outer = bar // 2 + 1 if persistent else int(bar % 2 == 0) + chosen = bar * (3 if bar % 2 == 0 else 2) + return chosen, 1 + (chosen + outer) % 5 + + +def run_values(pine, label): + return [ + tuple(map(float, line.split())) + for line in run_emitted_tu( + transpile(pine), DRIVER, opt="-O2", label=label + ).splitlines() + ] + + +def assert_located_refusal(pine, code, line, col): + # A translation refusal returns no TU to the native harness. + with pytest.raises(CompileError) as caught: + transpile(pine) + errors = [d for d in caught.value.diagnostics if d.code == code] + assert len(errors) == 1 + diagnostic = errors[0] + assert (diagnostic.location.line, diagnostic.location.col) == (line, col) + return diagnostic + + +@pytest.mark.parametrize("name", ("balance_v", "running_v")) +@pytest.mark.parametrize("op", (":=", "+=", "-=", "*=")) +@pytest.mark.parametrize("switch", (False, True)) +def test_requested_plain_outer_store_is_a_located_refusal(name, op, switch): + # Red: the requested arm's plain outer write compiled with unverified values. + # The accepted plain control's taped quantities are 2,5,4,3,1,1,3; + # plain outer writes remain a compile-agreement gap rather than a wrong TU. + pine = outer_source(False, name=name, op=op, switch=switch) + assert_located_refusal(pine, "PF-E3103", 6 if switch else 5, 9 if switch else 5) + + +def test_plain_assignment_records_the_accepted_value_before_refusal(): + # The chart's own declaration resets and its even arm adds one; the + # requested result is 3*b on even bars and 2*b on odd bars for this source. + expected = [outer_values(b, False)[1] for b in ENTRY_BARS] + assert expected == [2, 5, 4, 3, 1, 1, 3] + assert_located_refusal(outer_source(False), "PF-E3103", 5, 5) + + +@pytest.mark.parametrize("switch", (False, True)) +def test_requested_outer_store_uses_evaluator_storage(switch): + cpp = transpile(outer_source(True, switch=switch)) + evaluator = cpp.split("void _eval_security_0(", 1)[1].split( + "void evaluate_security(", 1)[0] + # A request writes its copy; only the chart executes the chart's write. + # The existing if rewrite already isolated its store; switch was refused. + assert "_sec0_balance_v" in evaluator + assert not re.search(r"(?= 5 for d in caught.value.diagnostics) + + +@pytest.mark.parametrize("declaration,mutation,read", ( + ("array items_v = array.new(1, 0.0)", + "array.set(items_v, 0, array.get(items_v, 0) + 1)", "array.get(items_v, 0)"), + ("array items_v = array.new()", + "items_v.push(1.0)", "items_v.size()"), + ("map items_v = map.new()", + 'map.put(items_v, "k", 1.0)', "map.size(items_v)"), +)) +@pytest.mark.parametrize("switch", (False, True)) +def test_requested_outer_collection_mutation_is_refused(declaration, mutation, read, switch): + selection = ("chosen = switch bar_index % 2\n 0 =>\n" + if switch else "chosen = if bar_index % 2 == 0\n") + indent = " " if switch else " " + tail = " => bar_index * 2\n" if switch else "else\n bar_index * 2\n" + pine = (HEADER + declaration + "\n" + selection + indent + mutation + "\n" + + indent + "bar_index * 3\n" + tail + f"got_v = {REQUEST}\n" + + f"qty_v = 1 + (got_v + int({read})) % 5\n") + # A method's mutation writes its receiver just as an assignment does. + assert_located_refusal(pine, "PF-E3103", 6 if switch else 5, 9 if switch else 5) + + +def test_requested_local_alias_does_not_hide_an_outer_collection_write(): + pine = (HEADER + "array items_v = array.from(0.0)\n" + "chosen = if bar_index % 2 == 0\n" + " alias_v = items_v\n alias_v.push(1.0)\n bar_index * 3\n" + "else\n bar_index * 2\n" + f"got_v = {REQUEST}\n") + # The alias names the same collection; its declaration does not detach it. + assert_located_refusal(pine, "PF-E3103", 6, 5) + + +def test_requested_helper_cannot_assign_a_global(): + pine = (HEADER + "float balance_v = 0.0\n" + "write_v(x) =>\n balance_v := balance_v + 1\n x * 3\n" + "got_v = request.security(syminfo.tickerid, timeframe.period, write_v(bar_index))\n") + # TradingView forbids global assignment inside a function (CE10088). + diagnostic = assert_located_refusal(pine, "PF-E3104", 5, 5) + assert "CE10088" in diagnostic.message + + +def test_requested_outer_read_keeps_its_values(): + pine = (HEADER + "var float balance_v = 0.0\nbalance_v += 1\n" + "chosen = if bar_index % 2 == 0\n bar_index * 3 + int(balance_v)\n" + "else\n bar_index * 2\n" + f"got_v = {REQUEST}\n" + "qty_v = 1 + got_v % 5\n" + SCHEDULE) + actual = run_values(pine, "outer read") + # The unconditional increment yields b+1; only the even value reads it. + expected = [(3*b + b+1 if b % 2 == 0 else 2*b) for b in range(60)] + assert actual == [(v, 1 + v % 5) for v in expected] + assert [actual[b][1] for b in ENTRY_BARS] == [2, 5, 3, 3, 4, 1, 5] + + +def test_requested_outer_collection_read_is_preserved(): + pine = (HEADER + "array items_v = array.from(7.0)\n" + "chosen = if bar_index % 2 == 0\n" + " array.get(items_v, 0) + bar_index * 3\nelse\n bar_index * 2\n" + + f"got_v = {REQUEST}\nqty_v = 1 + got_v % 5\n" + SCHEDULE) + actual = run_values(pine, "outer collection read") + # The read-only element is seven; selection and quantities still depend + # on every bar. This pins acceptance without adding a collection write. + expected = [3*b + 7 if b % 2 == 0 else 2*b for b in range(60)] + assert actual == [(v, 1 + v % 5) for v in expected] + + +@pytest.mark.parametrize("has_else", (True, False)) +def test_requested_helper_if_keeps_its_values(has_else): + pine = ( + HEADER + + "selected_v(x) =>\n" + " if x % 2 == 0\n" + " part_v = x * 3\n" + " part_v + 1\n" + + (" else\n x * 2\n" if has_else else "") + + "got_v = request.security(syminfo.tickerid, timeframe.period, " + "selected_v(bar_index))\n" + "qty_v = 1 + nz(got_v, 0) % 5\n" + + SCHEDULE + ) + actual = run_values(pine, "helper selected value") + # Green preservation pins: even b yields 3*b+1; odd b yields 2*b + # with else, or na (converted to zero for quantity) without else. + assert len(actual) == 60 + for bar, (value, quantity) in enumerate(actual): + expected = 3 * bar + 1 if bar % 2 == 0 else (2 * bar if has_else else math.nan) + assert math.isnan(value) if math.isnan(expected) else value == expected + assert quantity == 1 + (0 if math.isnan(expected) else expected) % 5 + tape = [2, 5, 4, 3, 1, 1, 3] if has_else else [2, 1, 4, 1, 1, 1, 3] + assert [actual[b][1] for b in ENTRY_BARS] == tape + + +def test_requested_void_helper_if_is_refused_at_call(): + pine = ( + HEADER + + "action_v(x) =>\n" + " if x > 6 and x % 7 == 3\n" + ' strategy.entry("L", strategy.long, qty = 2)\n' + "got_v = request.security(syminfo.tickerid, timeframe.period, action_v(bar_index))\n" + ) + # Red: a statement-only terminal if cannot supply a request value. + diagnostic = assert_located_refusal(pine, "PF-E3102", 6, 62) + assert diagnostic.args == {} + + +@pytest.mark.parametrize("terminal", ( + 'strategy.entry("L", strategy.long, qty = 2)', + 'strategy.close("L")', + 'alert("signal")', + "bgcolor(color.red)", +)) +@pytest.mark.parametrize("position", ("direct", "helper", "if", "switch")) +def test_requested_void_result_is_refused_in_every_value_position(terminal, position): + declarations = "" + if position == "direct": + value = terminal + elif position == "helper": + declarations = f"action_v() =>\n {terminal}\n" + value = "action_v()" + else: + selection = ( + f"if bar_index % 2 == 0\n {terminal}\n" + if position == "if" else + f"switch bar_index % 2\n 0 => {terminal}\n" + ) + declarations = "chosen = " + selection + value = "chosen" + pine = ( + HEADER + declarations + + f"got_v = request.security(syminfo.tickerid, timeframe.period, {value})\n" + ) + # Red where default-valued terminal lowering hid void; other forms pin + # the same refusal identity rather than a C++ or runtime failure. + with pytest.raises(CompileError) as caught: + transpile(pine) + assert any(d.code == "PF-E3102" for d in caught.value.diagnostics) diff --git a/tests/test_security_selection_storage.py b/tests/test_security_selection_storage.py new file mode 100644 index 00000000..33a02dde --- /dev/null +++ b/tests/test_security_selection_storage.py @@ -0,0 +1,67 @@ +"""Requested selections allocate state only for their lexical declarations.""" + +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.analyzer import Analyzer +from pineforge_codegen.codegen import CodeGen +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.parser import Parser +from tests.test_security_coof_restore_members import _inventory + + +@pytest.mark.parametrize("expression", ("close", "label_v", 'str.tostring(close)')) +def test_plain_request_does_not_allocate_selection_string_history(expression): + pine = ( + '//@version=6\nstrategy("Requested string storage")\n' + 'var string label_v = "ready"\n' + "got_v = request.security(syminfo.tickerid, timeframe.period, close)\n" + f"label_result_v = request.security(syminfo.tickerid, timeframe.period, {expression})\n" + ) + cpp = transpile(pine) + # A top-level var has its own request state; no selection-local string + # history map, checkpoint field or reset may be added for it. + assert "_security_helper_series_str_" not in cpp + assert not re.search(r"unordered_map>", cpp) + inventory = _inventory(cpp) + assert "_security_helper_series_str_" not in inventory + # The checkpoint inventory must contain each declared state once. + class_body = cpp.split("class GeneratedStrategy :", 1)[1].split( + " struct _PFScriptState {", 1)[0] + declarations = set(re.findall( + r"^ [^\n;]+? (\w+)(?:\{[^;\n]*\}| = [^;\n]*)?;", + class_body, re.M)) + # This source has no TA caches or varip state; only the immutable + # precalculation mode flag is outside the rollback inventory. + declared_state = declarations - {"_use_precalc"} + assert set(inventory) == declared_state + assert len(inventory) == len(declared_state) + + +def test_requested_selection_string_var_keeps_its_series_map(): + pine = ( + '//@version=6\nstrategy("Local string state")\n' + "chosen = if bar_index >= 0\n" + ' var string label_v = "ready"\n label_v\nelse\n "waiting"\n' + "got_v = request.security(syminfo.tickerid, timeframe.period, chosen)\n" + ) + # A var declared inside the requested arm needs the string history map. + assert "_security_helper_series_str_" in transpile(pine) + + +def test_requested_arm_average_has_one_lazy_site(): + pine = ( + '//@version=6\nstrategy("Conditional average")\n' + "chosen = if bar_index % 2 == 0\n" + " averaged_v = ta.sma(close, 3)\n averaged_v\nelse\n 0.0\n" + "got_v = request.security(syminfo.tickerid, timeframe.period, chosen)\n" + ) + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + generator = CodeGen(Analyzer(ast).analyze()) + generator.generate() + item = generator._security_calls[0] + info = generator._security_eval_info[0] + # Reading the arm's local reads its computed value, not a second TA call. + assert len(generator._security_lazy_ta_keys(0, item["expr_node"], info)) == 1 diff --git a/tests/test_security_selection_tv_replies.py b/tests/test_security_selection_tv_replies.py new file mode 100644 index 00000000..d82724e8 --- /dev/null +++ b/tests/test_security_selection_tv_replies.py @@ -0,0 +1,81 @@ +"""Forms of a requested if/switch whose TradingView reply is known. + +Each row cites the reply: a refusal with its code, or an acceptance with the first +entry quantities of its tape. The accepted rows only assert that translation +succeeds; the values are measured by the population and value tables, not here. +""" +from __future__ import annotations + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError + +HEADER = ( + '//@version=6\n' + 'strategy("selection reply", currency = currency.NONE, initial_capital = 1000000)\n' +) +ORDERS = ( + 'if bar_index > 6 and bar_index % 7 == 3\n' + ' strategy.entry("L", strategy.long, qty = 1)\n' + 'if bar_index > 6 and bar_index % 7 == 5\n' + ' strategy.close("L")\n' +) + + +def _refusal(source: str) -> CompileError: + with pytest.raises(CompileError) as caught: + transpile(source) + return caught.value + + +def test_mixed_value_and_void_arm_in_a_request_is_refused_like_tradingview(): + # TradingView CE10235: the blocks of one if return incompatible types (one + # value arm, one void arm), line 3. The request form is refused at the + # requested value, the same refusal as a void helper result. + source = HEADER + ( + 'chosen = if bar_index % 2 == 0\n' + ' bar_index * 3\n' + 'else\n' + ' strategy.close("L")\n' + 'got_v = request.security(syminfo.tickerid, timeframe.period, chosen)\n' + 'qty_v = 1 + int(nz(got_v)) % 5\n' + 'if bar_index > 6 and bar_index % 7 == 3\n' + ' strategy.entry("L", strategy.long, qty = qty_v)\n' + ) + (diagnostic,) = _refusal(source).diagnostics + assert diagnostic.code == "PF-E3102" + assert diagnostic.location.line == 7 + + +def test_if_expression_as_a_call_argument_is_a_located_refusal(): + # TradingView CE10156 (syntax error) at 4:5: the if block cannot stand in a + # call argument. PineForge's parser refuses it at the same position. + source = HEADER + ( + 'probe_v = math.abs(if bar_index % 2 == 0\n' + ' bar_index * 3\n' + 'else\n' + ' bar_index * 2)\n' + ) + ORDERS + (diagnostic,) = _refusal(source).diagnostics + assert (diagnostic.location.line, diagnostic.location.col) == (4, 5) + + +@pytest.mark.parametrize("body", [ + # TradingView accepts an if expression bound to a name used as a ternary + # condition (its tape: 26 entries, first quantities 1,2,1,2,1,2,1). + 'cond_v = if bar_index % 2 == 0\n' + ' true\n' + 'else\n' + ' false\n' + 'probe_v = cond_v ? 1 : 2\n', + # TradingView accepts a series condition choosing a request timeframe + # (26 entries, first quantities all 1). + 'tf_v = if close > open\n' + ' "60"\n' + 'else\n' + ' "240"\n' + 'got_v = request.security(syminfo.tickerid, tf_v, close)\n', +]) +def test_forms_tradingview_accepts_still_translate(body): + assert isinstance(transpile(HEADER + body + ORDERS), str) diff --git a/tests/test_selection_value_grammar.py b/tests/test_selection_value_grammar.py new file mode 100644 index 00000000..4e51d532 --- /dev/null +++ b/tests/test_selection_value_grammar.py @@ -0,0 +1,437 @@ +"""Grammar slots and typed lowering of block selection values.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from pineforge_codegen import transpile, transpile_full +from pineforge_codegen.analyzer import Analyzer +from pineforge_codegen.ast_nodes import ASTNode, Identifier, VarDecl +from pineforge_codegen.codegen import CodeGen +from pineforge_codegen.errors import CodegenInvariantError, CompileError, Level, Phase +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.parser import Parser +from tests._compile import compile_cpp, run_emitted_tu + + +FIXTURES = Path(__file__).parent / "fixtures" / "selection_values" +ROOT = Path(__file__).resolve().parent.parent + + +def _source(name: str) -> str: + return (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") + + +@pytest.fixture +def protocol(): + namespace = {} + glue = (ROOT / "gate" / "glue.py").read_text(encoding="utf-8") + exec(compile(glue.replace('"/codegen"', repr(str(ROOT))), "glue.py", "exec"), + namespace) + return lambda source: json.loads(namespace["transpile_json"](source)) + + +# These locations count from the first authored compound keyword, not from +# TradingView's later recovery token. Each fixture has its own refusal tape. +REFUSED = [ + # Five callable comma tails share eight spaces and a 25-character prefix. + ("price-close", 6, 34, "if", "CE10013"), + ("price-high", 6, 34, "if", "CE10013"), + ("price-hl2", 6, 34, "if", "CE10013"), + ("price-low", 6, 34, "if", "CE10013"), + ("price-open", 6, 34, "if", "CE10013"), + # Four spaces plus "0 => " / "=> " put the keyword at column 10 / 8. + ("nested-case", 6, 10, "switch", "CE10013"), + ("nested-default", 8, 8, "switch", "CE10013"), + # The assignment's "value := " contributes nine more characters. + ("inline-assignment-switch", 6, 19, "switch", "CE10013"), + # Four spaces plus the case prefix precede the compound. + ("inline-case-if", 5, 10, "if", "CE10013"), + ("inline-case-switch", 6, 10, "switch", "CE10013"), + # The comma sequence's complete simple statement precedes the compound. + ("inline-comma-if", 6, 34, "if", "CE10013"), + ("inline-comma-switch", 5, 29, "switch", "CE10013"), + # The declaration's "chosen = " also contributes nine characters. + ("inline-declaration-switch", 5, 19, "switch", "CE10013"), + # Four spaces plus "=> " precede each default-arm compound. + ("inline-default-if", 6, 8, "if", "CE10013"), + ("inline-default-switch", 8, 8, "switch", "CE10013"), + # Reject the outer inline if before descending into its valid block. + ("inline-if-with-switch", 5, 10, "if", "CE10013"), + # Function/method arms have eight spaces plus "0 => ". + ("inline-method-switch", 6, 14, "switch", "CE10013"), + ("inline-unreached-function", 6, 14, "switch", "CE10013"), + # A discarded result obeys the same grammar before statement emission. + ("inline-unused-switch", 6, 10, "switch", "CE10013"), +] + + +@pytest.mark.parametrize( + "name,line,col,keyword,tape", REFUSED, + ids=[f"{row[0]}-{row[4]}" for row in REFUSED], +) +def test_inline_compounds_stop_before_emission( + monkeypatch, protocol, name, line, col, keyword, tape, +): + def no_codegen(*args, **kwargs): + pytest.fail("A grammar refusal must happen before code generation") + + monkeypatch.setattr(CodeGen, "__init__", no_codegen) + source = _source(name) + for check_support in (True, False): + with pytest.raises(CompileError) as caught: + transpile(source, filename="selection.pine", check_support=check_support) + diagnostic, = caught.value.diagnostics + assert diagnostic.level is Level.ERROR + assert diagnostic.phase is Phase.PARSER + assert diagnostic.code == "PF-E0037" + assert diagnostic.args == {"token_type": keyword.upper(), "token": keyword} + assert diagnostic.location.file == "selection.pine" + assert (diagnostic.location.line, diagnostic.location.col) == (line, col) + assert diagnostic.location.end_col == col + len(keyword) + envelope = protocol(source) + assert not envelope["ok"] + assert "cpp" not in envelope + diagnostic, = envelope["diagnostics"] + assert (diagnostic["code"], diagnostic["line"], diagnostic["col"]) == ( + "PF-E0037", line, col, + ) + + +SLOT_REFUSALS = [ + # "for i = " places a structured start at column 9. + ("for-start-if", 5, 9, "if", "CE10161"), + ("for-start-switch", 5, 9, "switch", "CE10161"), + # "for i = 0 to " places a structured end at column 14. + ("for-end-if", 5, 14, "if", "CE10161"), + ("for-end-switch", 5, 14, "switch", "CE10161"), + # Four spaces plus "float value = " put a field default at column 19. + ("udt-default-if", 5, 19, "if", "CE10133"), + ("udt-default-switch", 5, 19, "switch", "CE10133"), + # Four spaces plus "first = " put an enum title at column 13. + ("enum-title-switch", 5, 13, "switch", "CE10133"), + # The false operand starts after the question, true operand and colon. + # CE10150 here names the authored if/switch keyword in expression position + # on line 4; it is not an identifier-name error. + ("ternary-if", 4, 36, "if", "CE10150"), + ("ternary-switch", 4, 36, "switch", "CE10150"), +] + + +@pytest.mark.parametrize( + "name,line,col,keyword,tape", SLOT_REFUSALS, + ids=[f"{row[0]}-{row[4]}" for row in SLOT_REFUSALS], +) +def test_expression_only_parent_slots_refuse_blocks(protocol, name, line, col, keyword, tape): + # Each parent production admits an expression; the compound's own token + # is the first point that violates that production. + envelope = protocol(_source(name)) + assert not envelope["ok"] and "cpp" not in envelope + diagnostic, = envelope["diagnostics"] + assert (diagnostic["code"], diagnostic["line"], diagnostic["col"]) == ( + "PF-E0037", line, col, + ) + assert diagnostic["args"] == {"token_type": keyword.upper(), "token": keyword} + + +def test_delimited_switch_ce10156_keeps_its_existing_parser_refusal(protocol): + # Green before and after: delimiter depth suppresses the newline, so the + # switch header sees the first case's 0 at line 5, column 5 instead. + envelope = protocol(_source("delimited-switch")) + assert not envelope["ok"] and "cpp" not in envelope + diagnostic, = envelope["diagnostics"] + assert (diagnostic["code"], diagnostic["line"], diagnostic["col"]) == ( + "PF-E0025", 5, 5, + ) + + +# Except the if timeframe alias, these are acceptance/compile preservation +# pins, green before and after. Sources and tape names are identical. +ACCEPTED = [ + "block-assignment-switch", "block-case-if", "block-case-switch", + "block-collection-switch", "block-comma-if", "block-comma-switch", + "block-declaration-switch", "block-default-if", "block-default-switch", + "block-function-switch", "block-if-with-switch", "block-method-switch", + "block-no-default-switch", "block-side-effects-switch", "block-tuple-switch", + "block-unreached-function", "block-var-switch", + "header-function-if", "header-function-switch", "header-method-switch", + "security-if-alias", "security-switch-alias", + "security-timeframe-if", "security-timeframe-switch", + "comma-simple-arm", "literal-scalar-twins", "udt-default-literal", + "for-literal-bounds", +] + + +def _assert_value_output(cpp: str) -> None: + assert "/* unknown */" not in cpp + assert "/* null */" not in cpp + assert not re.search(r"(?])=(?:\s|/\*.*?\*/)*;", cpp, re.DOTALL) + + +@pytest.mark.parametrize("name", ACCEPTED) +def test_accepted_block_values_translate(name): + # The block parser leaves bare selections; typed assignment recursion + # emits the terminal value without a scalar placeholder. + _assert_value_output(transpile(_source(name))) + + +@pytest.mark.parametrize("name", ACCEPTED) +def test_accepted_block_values_compile(name): + compile_cpp(transpile(_source(name)), label=name) + + +def test_unmeasured_typed_blocks_remain_accepted(): + # This source's keyword-name error measured no string-branch behavior. + # Preserve admission; nested bool/string tails must retain their types. + cpp = transpile(_source("block-typed-values")) + _assert_value_output(cpp) + compile_cpp(cpp, label="typed nested blocks") + + +VALUE_ROWS = [ + # Outer residue 0 selects inner parity 1/2; all other residues yield 3. + ("block-case-if", "value", "1 3 3 2 3 3"), + ("block-case-switch", "value", "1 3 3 2 3 3"), + ("block-assignment-switch", "value", "1 3 3 2 3 3"), + ("block-declaration-switch", "value", "1 3 3 2 3 3"), + ("block-comma-switch", "value", "1 3 3 2 3 3"), + ("block-function-switch", "value", "1 3 3 2 3 3"), + ("block-method-switch", "value", "1 3 3 2 3 3"), + ("block-collection-switch", "array.get(values, 0)", "1 3 3 2 3 3"), + # Default residues use parity after the outer cases 0 and 1. + ("block-default-switch", "value", "1 2 3 1 2 4"), + ("block-default-if", "value", "1 3 2 1 2 3"), + # n=0 reaches the deepest zero case; n=3 takes the odd branch. + ("block-if-with-switch", "value", "1 4 4 3 4 4"), + # An unmatched inner switch yields na rather than its previous value. + ("block-no-default-switch", "value", "1 3 3 na 3 3"), + # Each selected tuple's two elements sum to 11, 22 or 33. + ("block-tuple-switch", "value + other", "11 33 33 22 33 33"), + # The first declaration selects 1 and its one-shot latch retains it. + ("block-var-switch", "value", "1 1 1 1 1 1"), + # Exactly one selected push occurs on each of the six bars. + ("block-side-effects-switch", "array.size(values)", "1 2 3 4 5 6"), + # The callable is never reached; its ternary always chooses 4. + ("block-unreached-function", "value", "4 4 4 4 4 4"), + # A close of 100 gives x+1.125 on even bars and -x on odd bars. + ("block-comma-if", "value", "101.125 100 101.125 100 101.125 100"), + # Header compounds and simple comma arms select by bar parity. + ("header-function-if", "value", "1 2 1 2 1 2"), + ("header-function-switch", "value", "1 2 1 2 1 2"), + ("header-method-switch", "value", "1 2 1 2 1 2"), + ("comma-simple-arm", "value", "1 2 1 2 1 2"), +] + + +@pytest.mark.parametrize("name,observation,expected", VALUE_ROWS, + ids=[row[0] for row in VALUE_ROWS]) +def test_block_selection_values_and_effects(name, observation, expected): + # Add only an observable read after the byte-exact taped strategy. + source = _source(name) + f"observed = {observation}\n" + cpp = transpile(source) + driver = r''' +#include +int main() { + Bar bars[6]; + for (int i = 0; i < 6; ++i) + bars[i] = Bar{100.0, 101.0, 99.0, 100.0, 1.0, + 1743465600000LL + i * 900000LL}; + for (int count = 1; count <= 6; ++count) { + GeneratedStrategy strategy; + strategy.run(bars, count); + if (!strategy.last_error().empty()) return 1; + if (is_na(strategy.observed)) std::cout << "na"; + else std::cout << strategy.observed; + std::cout << " "; + } +} +''' + assert run_emitted_tu(cpp, driver, opt="-O2", label=name).split() == expected.split() + + +def _generator(source: str) -> tuple[CodeGen, object]: + ast = Parser(Lexer(source).tokenize(), source=source, filename="value.pine").parse() + return CodeGen(Analyzer(ast, filename="value.pine").analyze()), ast + + +def test_missing_value_lowering_raises_an_internal_invariant(): + source = '//@version=6\nstrategy("value")\nvalue = close\n' + gen, ast = _generator(source) + declaration = next(stmt for stmt in ast.body if isinstance(stmt, VarDecl)) + unsupported = ASTNode(loc=declaration.value.loc) + # The old dispatcher returned a comment for this otherwise unhandled kind. + with pytest.raises(CodegenInvariantError) as caught: + gen._visit_expr(unsupported) + assert caught.value.node is unsupported + + +@pytest.mark.parametrize("translate", [transpile, transpile_full]) +def test_translation_boundary_reports_the_internal_invariant(monkeypatch, protocol, translate): + original = CodeGen._visit_expr + + def missing_lowering(self, node): + if isinstance(node, Identifier) and node.name == "close": + node = ASTNode(loc=node.loc) + # Ordinary resolution retries must not swallow a lowering invariant. + try: + return original(self, node) + except Exception: + return "0.0" + + monkeypatch.setattr(CodeGen, "_visit_expr", missing_lowering) + source = '//@version=6\nstrategy("value")\nvalue = close\n' + # The unsupported value inherits close's actual authored location. + with pytest.raises(CompileError) as caught: + translate(source, filename="value.pine") + diagnostic, = caught.value.diagnostics + assert diagnostic.phase is Phase.CODEGEN + assert diagnostic.code == "PF-E5083" + assert diagnostic.args == {"node_kind": "ASTNode"} + assert (diagnostic.location.file, diagnostic.location.line, + diagnostic.location.col) == ("value.pine", 3, 9) + envelope = protocol(source) + assert not envelope["ok"] and "cpp" not in envelope + assert envelope["diagnostics"][0]["code"] == "PF-E5083" + + +@pytest.mark.parametrize("translate", [transpile, transpile_full]) +def test_unlocated_missing_lowering_reports_the_missing_location(monkeypatch, translate): + original = CodeGen._visit_expr + + def missing_lowering(self, node): + if isinstance(node, Identifier) and node.name == "close": + node = ASTNode() + return original(self, node) + + monkeypatch.setattr(CodeGen, "_visit_expr", missing_lowering) + source = '//@version=6\nstrategy("value")\nvalue = close\n' + # An unlocated internal value cannot truthfully be attributed to line 1. + with pytest.raises(RuntimeError, match="AST node 'ASTNode' has no source location"): + translate(source, filename="value.pine") + + +@pytest.mark.parametrize( + "branches,call,expected_type,expected", + [ + # A typed numeric argument evaluates the selected branch on each bar. + ("1.25\nelse\n 2.5", "math.abs(chosen)", "double", "1.25 2.5"), + # A collection element consumes exactly the same numeric value. + ("1.25\nelse\n 2.5", "array.get(array.from(chosen), 0)", + "double", "1.25 2.5"), + # An array-valued selection keeps its element type and selected ID. + ("array.from(1.25)\nelse\n array.from(2.5)", "array.get(chosen, 0)", + "std::vector", "1.25 2.5"), + # String consumers must receive a string, never a numeric temporary. + ('"one"\nelse\n "second"', "str.length(chosen)", + "std::string", "3 6"), + # Boolean consumers keep the selected bool without numeric conversion. + ("true\nelse\n false", "chosen ? 7 : 9", "bool", "7 9"), + # A ternary's selected numeric operand evaluates its block lazily. + ("1.25\nelse\n 2.5", "bar_index == 0 ? chosen : 9.0", + "double", "1.25 9"), + ], + ids=["numeric-argument", "collection-element", "array-argument", "string-argument", + "bool-condition", "ternary-branch"], +) +@pytest.mark.parametrize("selection_kind", ["if", "switch"]) +def test_substituted_block_values_share_the_typed_expression_lowering( + branches, call, expected_type, expected, selection_kind, +): + if selection_kind == "if": + selection = "if bar_index % 2 == 0\n " + branches + else: + first, last = branches.split("\nelse\n ") + selection = f"switch bar_index % 2\n 0 => {first}\n => {last}" + source = ( + '//@version=6\nstrategy("substituted block")\n' + 'chosen = ' + selection + + '\nobserved = ' + call + '\n' + ) + gen, ast = _generator(source) + selected = next(stmt.value for stmt in ast.body + if isinstance(stmt, VarDecl) and stmt.name == "chosen") + observed = next(stmt for stmt in ast.body + if isinstance(stmt, VarDecl) and stmt.name == "observed") + + def expand(value): + # Model alias substitution after grammar validation and analysis. + # No authored compound is being admitted into a refused syntax slot. + if isinstance(value, Identifier) and value.name == "chosen": + return selected + if isinstance(value, ASTNode): + for name, item in vars(value).items(): + if name not in ("loc", "annotations"): + setattr(value, name, expand(item)) + elif isinstance(value, list): + return [expand(item) for item in value] + elif isinstance(value, dict): + return {name: expand(item) for name, item in value.items()} + return value + + observed.value = expand(observed.value) + expr = gen._visit_expr(selected) + assert f"-> {expected_type}" in expr + cpp = gen.generate() + _assert_value_output(cpp) + driver = r''' +#include +int main() { + Bar bars[2] = { + {100.0, 101.0, 99.0, 100.0, 1.0, 1743465600000LL}, + {100.0, 101.0, 99.0, 100.0, 1.0, 1743466500000LL} + }; + for (int count : {1, 2}) { + GeneratedStrategy strategy; + strategy.run(bars, count); + if (!strategy.last_error().empty()) return 1; + std::cout << strategy.observed << " "; + } +} +''' + assert run_emitted_tu(cpp, driver, opt="-O2", + label="substituted block value").split() == expected.split() + + +def test_timeframe_if_reads_inputs_before_the_first_bar(): + cpp = transpile(_source("security-timeframe-if")) + setup = cpp.split("void configure_security_evaluators() override", 1)[1] + setup = setup.split("void ", 1)[0] + # Before on_bar, pick's member has not received its input; its getter is + # the only correct condition for the std::string-valued setup lambda. + assert "-> std::string" in setup + assert 'get_input_bool("Choose chart"' in setup + assert not re.search(r"\bif \(pick\)", setup) + assert "script_tf_" in setup and 'std::string("1D")' in setup + + +def test_foreign_symbol_if_uses_the_same_setup_value_lowering(): + source = ( + '//@version=6\nstrategy("selected symbol")\n' + 'choose = input.bool(true, "Choose symbol")\n' + 'symbol = if choose\n "BINANCE:ETHUSDT"\n' + 'else\n "BINANCE:BTCUSDT"\n' + 'value = request.security(symbol, "60", close)\nplot(value)\n' + ) + cpp = transpile(source) + _assert_value_output(cpp) + # A display-only request becomes na; exercise the registration consumer + # itself with the analyzed symbol rather than assuming a feed is emitted. + gen, ast = _generator(source) + symbol = next(stmt.value for stmt in ast.body + if isinstance(stmt, VarDecl) and stmt.name == "symbol") + lines = [] + gen._emit_foreign_security_registration( + {"sec_id": 0, "symbol_node": symbol}, + 'std::string("60")', "false", "false", lines, + ) + # The consumer renders its symbol through the same setup-time adapter. + registration = "\n".join(lines).split("const std::string _pf_symbol = ", 1)[1] + registration = registration.split("_pf_symbol_is_chart", 1)[0] + assert "-> std::string" in registration + assert 'get_input_bool("Choose symbol"' in registration + assert "BINANCE:ETHUSDT" in registration and "BINANCE:BTCUSDT" in registration + compile_cpp(cpp, label="selected foreign symbol") diff --git a/tests/test_timeframe_member_types.py b/tests/test_timeframe_member_types.py new file mode 100644 index 00000000..428236a3 --- /dev/null +++ b/tests/test_timeframe_member_types.py @@ -0,0 +1,61 @@ +"""Timeframe member types follow Pine's int, bool and string contracts.""" + +import re + +from pineforge_codegen import transpile + + +def test_timeframe_multiplier_arithmetic_and_float_comparison(): + pine = ('//@version=6\nstrategy("Timeframe integer")\n' + 'frameMultiple = timeframe.multiplier\n' + 'nextMultiple = frameMultiple + 1.0\n' + 'aboveFraction = frameMultiple > 1.5\n' + 'plot(nextMultiple)\nplot(aboveFraction ? 1 : 0)\n') + cpp = transpile(pine) + # Preservation pin: the INT signature and _type_for_decl give integral storage. + assert re.search(r"\bint frameMultiple\s*=", cpp) + assert "frameMultiple = tf_multiplier(script_tf_);" in cpp + # _lower_binop keeps numeric +; an int/double relational uses the float band. + assert "(frameMultiple + 1.0)" in cpp + assert "auto _pna_l = (frameMultiple); auto _pna_r = (1.5);" in cpp + assert "double _pfc_l = static_cast(_pna_l);" in cpp + assert "std::fabs(_pfc_l - _pfc_r) <= 1e-10" in cpp + assert "((_pfc_l > _pfc_r) && !_pfc_eq)" in cpp + + +def test_timeframe_flags_are_bool_conditions(): + pine = ('//@version=6\nstrategy("Timeframe booleans")\n' + 'intradayValue() => timeframe.isintraday\n' + 'dailyValue() => timeframe.isdaily\n' + 'intradayFlag = timeframe.isintraday\n' + 'dailyFlag = timeframe.isdaily\n' + 'if intradayFlag\n strategy.entry("Long", strategy.long)\n' + 'pickedDaily = dailyFlag ? 1 : 0\n' + 'pickedBoth = intradayValue() and dailyValue() ? 1 : 0\n') + cpp = transpile(pine) + # Preservation pin: BOOL signatures give bool slots and unchanged conditions. + assert re.search(r"\bbool intradayFlag\s*=", cpp) + assert re.search(r"\bbool dailyFlag\s*=", cpp) + assert "intradayFlag = tf_is_intraday(script_tf_);" in cpp + assert "dailyFlag = tf_is_daily(script_tf_);" in cpp + assert "if (intradayFlag)" in cpp + # The flag is the ternary's condition itself, whatever the parenthesisation + # (a global assignment emits `name = ((cond) ? (1) : (0));`, as the sibling + # ternary rows of the suite show). + assert re.search(r"\bpickedDaily\s*=\s*\(*dailyFlag\)*\s*\?\s*\(*1\)*\s*:\s*\(*0", cpp) + # Both helpers are called, so both are emitted. + assert re.search(r"\bpickedBoth\s*=", cpp) + # _emit_func maps each analyzer return type to bool, never std::string. + assert re.search(r"\bbool\s+intradayValue\s*\(", cpp) + assert re.search(r"\bbool\s+dailyValue\s*\(", cpp) + + +def test_timeframe_period_stays_string_in_string_functions(): + pine = ('//@version=6\nstrategy("Timeframe string")\n' + 'periodName = timeframe.period\n' + 'periodSize = str.length(periodName)\nplot(periodSize)\n') + cpp = transpile(pine) + # Preservation pin: STRING storage and str.length's table use .length(). + assert re.search(r"\bstd::string periodName\s*=", cpp) + assert "periodName = script_tf_;" in cpp + assert "(int)periodName.length()" in cpp