From 74a67ad2b6ec474c10730b0885d6ad10918ca877 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 16:58:26 +0800 Subject: [PATCH 01/14] Stop or omit a chart loop whose end bound can never stop it, as TradingView does A counted for loop whose end is affine in its own counter can be endless: for i = 0 to i never fails its test (the end grows with the counter). We emitted a loop that never ends, so the run hung. TradingView does not hang: it keeps the script only when the loop's result is never used, and otherwise stops the run on the first bar with "Loop takes too long to execute". Measured on TradingView (entry quantity and entry count of every probe; tape and source digests are in tests/fixtures/endless_for/tradingview.json): - An endless loop whose result reaches an effect (a plotted or traded value, directly or through other variables) stops the script on bar 0 with the error above: no trade is entered. - An endless loop whose result is never read is dropped: the strategy trades as if the loop were absent (a 205-entry tape, identical to the same script without the loop), including when the loop body has a conditional break and when the start is not zero. - A chain of variables that never reaches an effect is dead as a whole, and a loop inside a function that is never called does not run. What changes in generated code (chart context only): - A loop is classed endless only when its end is affine in its own counter with constant coefficients, its start is constant, its step is positive or absent, and its body has no reachable break or return (a dead loop is omitted whether or not it has a break). For a non-zero start both candidate first end reads must agree. - A loop classed endless whose result reaches an effect (a transitive reading of what the loop writes, where the conditions of control constructs that contain effects count and a call we cannot see into counts as an effect) emits the run stop "Error on bar N: Loop takes too long to execute (> 2000 ms)". The limit is a named constant (kLoopTimeLimitMs) emitted only when such a loop exists. - A loop classed endless whose result reaches no effect is omitted. - Loops in function bodies and in request.security helpers are untouched; every other loop emits the same C++ as before. Known gaps: only statically endless loops are handled; a loop that is merely slow, or whose end depends on data, still runs to its natural end (a runtime time limit is a separate change); the effect analysis is conservative, so a loop whose result only looks live is stopped. Tests: tests/test_for_endless_loops.py (classification and emission, including dead chains and a function that is never called) and tests/test_e2e_for_endless_loops.py (the generated C++ compiled and run; the stop text, the omitted loop's entry count). The one existing test whose loop this rule changes (for i = 0 to i, now endless) is given a terminating end (10 - i) so it still checks the counter shadowing an outer series. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 12 +- CHANGELOG.md | 13 +- CLAUDE.md | 12 +- pineforge_codegen/codegen/emit_top.py | 5 + pineforge_codegen/codegen/endless_for.py | 507 ++++++++++++++++++ pineforge_codegen/codegen/visit_stmt.py | 14 + tests/_endless_for_cases.py | 163 ++++++ tests/fixtures/endless_for/dead_chain.pine | 10 + tests/fixtures/endless_for/dead_counter.pine | 9 + .../endless_for/dead_counter_plus_one.pine | 9 + .../endless_for/dead_counter_with_break.pine | 11 + .../endless_for/dead_nonzero_start.pine | 9 + .../function_called_in_dead_branch.pine | 13 + tests/fixtures/endless_for/live_counter.pine | 11 + .../endless_for/live_counter_plus_one.pine | 11 + .../endless_for/live_nonzero_start.pine | 11 + .../endless_for/live_single_bar_read.pine | 11 + .../endless_for/live_untaken_branch_read.pine | 11 + .../endless_for/prior_cpp_baseline.json | 76 +++ tests/fixtures/endless_for/tradingview.json | 82 +++ .../endless_for/uncalled_function_loop.pine | 11 + tests/test_codegen_validation_fixes.py | 7 +- tests/test_e2e_for_endless_loops.py | 114 ++++ tests/test_for_endless_loops.py | 393 ++++++++++++++ 24 files changed, 1512 insertions(+), 13 deletions(-) create mode 100644 pineforge_codegen/codegen/endless_for.py create mode 100644 tests/_endless_for_cases.py create mode 100644 tests/fixtures/endless_for/dead_chain.pine create mode 100644 tests/fixtures/endless_for/dead_counter.pine create mode 100644 tests/fixtures/endless_for/dead_counter_plus_one.pine create mode 100644 tests/fixtures/endless_for/dead_counter_with_break.pine create mode 100644 tests/fixtures/endless_for/dead_nonzero_start.pine create mode 100644 tests/fixtures/endless_for/function_called_in_dead_branch.pine create mode 100644 tests/fixtures/endless_for/live_counter.pine create mode 100644 tests/fixtures/endless_for/live_counter_plus_one.pine create mode 100644 tests/fixtures/endless_for/live_nonzero_start.pine create mode 100644 tests/fixtures/endless_for/live_single_bar_read.pine create mode 100644 tests/fixtures/endless_for/live_untaken_branch_read.pine create mode 100644 tests/fixtures/endless_for/prior_cpp_baseline.json create mode 100644 tests/fixtures/endless_for/tradingview.json create mode 100644 tests/fixtures/endless_for/uncalled_function_loop.pine create mode 100644 tests/test_e2e_for_endless_loops.py create mode 100644 tests/test_for_endless_loops.py diff --git a/AGENTS.md b/AGENTS.md index 573c1c82..40edf970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,8 +398,14 @@ stops with RE10021 at bar 0, a known gap against TradingView's refusal of a floa step with an int counter (`by 0.5` narrows to 0, where earlier releases replaced it with 1). Without float evidence, counters keep their existing int route. v5 library bodies keep their int counters and existing step handling. The counter -now wins over an outer same-named variable; an end bound that is exactly its own -counter never ends here, while TradingView stops it (a follow-up change). +now wins over an outer same-named variable. An affine chart loop proved endless +(its end is affine in its own counter) is stopped with the loop-time error when +its result reaches an effect. Omission is limited to an unused loop statement +with no calls, whose writes have no readers except self-updates, a break +condition or one further unused variable declaration. Other flagged loops stop +when their result is read in a shape not measured, where TradingView may drop +them. Endless loops in functions, methods and `request.security` still never end +here while TradingView stops them; data-dependent endless loops still hang. The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are `end_reads_counter` = 1, `end_reads_counter_other` = 3, @@ -417,7 +423,7 @@ The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are zero entries. Non-positive-step probes also measure no-trade stops. Known gaps: a start with positive int evidence and a float step is refused by TradingView; here a narrowed non-positive step stops with RE10021 at bar 0; -the endless own-counter bound hangs here pending a follow-up change; a float +a float counter passed to an untyped helper truncates here while TradingView passes the fractional value (`twice(x)` accumulates 2 here versus 5 on TradingView). `array.from(x)` can also truncate a float counter through inferred `array` diff --git a/CHANGELOG.md b/CHANGELOG.md index 81dc75be..c09be12c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ supported as exact pairs; on the 0.x line they are independent. See the ## Unreleased (next release) +- An affine chart loop proved endless is stopped with the loop-time error when + its result reaches an effect. Omission is limited to an unused loop statement + with no calls, whose writes have no readers except self-updates, a break + condition or one further unused variable declaration. Other flagged loops + stop when their result is read in a shape not measured, where TradingView + may drop them. Endless loops in functions, methods and `request.security` + still never end here while TradingView stops them; data-dependent endless + loops still hang. - Pine v6 calls refuse a positional argument after a named argument with `PF-E2074` at that positional expression. The check also covers strategy declarations, unused functions and v6 library bodies before lowering. @@ -47,8 +55,7 @@ supported as exact pairs; on the 0.x line they are independent. See the route is unchanged; v5 library loops keep their existing int-counter route. Counter-family inference follows declaration sources in a dedicated query, independently of shared expression and storage inference. -- Known loop gaps: an end bound that is exactly its own counter runs indefinitely - here, while TradingView stops it; a start with positive int evidence and a +- Known loop gaps: a start with positive int evidence and a float step is refused by TradingView, while a step narrowed to zero or below stops here with RE10021 at bar 0. A float counter passed to an untyped helper truncates here, while TradingView passes the fractional @@ -61,8 +68,6 @@ supported as exact pairs; on the 0.x line they are independent. See the `0.0 to 0.3 by 0.1` counts three here and four on TradingView, whereas `0.0 to 1.0 by 0.1` counts eleven on both. Block-local/enclosing-counter bindings and stops inside `request.security` have no TradingView tape. - This change merges first; a follow-up change closes the endless-bound hang, - with no codegen release in between. - A Pine v6 function or method reading a later local without an earlier visible binding is refused during analysis at the read (`PF-E2075`), using the same declaration-order rule as later globals (`PF-E2053`). diff --git a/CLAUDE.md b/CLAUDE.md index f00ae49f..f5c7890a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -398,8 +398,14 @@ stops with RE10021 at bar 0, a known gap against TradingView's refusal of a floa step with an int counter (`by 0.5` narrows to 0, where earlier releases replaced it with 1). Without float evidence, counters keep their existing int route. v5 library bodies keep their int counters and existing step handling. The counter -now wins over an outer same-named variable; an end bound that is exactly its own -counter never ends here, while TradingView stops it (a follow-up change). +now wins over an outer same-named variable. An affine chart loop proved endless +(its end is affine in its own counter) is stopped with the loop-time error when +its result reaches an effect. Omission is limited to an unused loop statement +with no calls, whose writes have no readers except self-updates, a break +condition or one further unused variable declaration. Other flagged loops stop +when their result is read in a shape not measured, where TradingView may drop +them. Endless loops in functions, methods and `request.security` still never end +here while TradingView stops them; data-dependent endless loops still hang. The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are `end_reads_counter` = 1, `end_reads_counter_other` = 3, @@ -417,7 +423,7 @@ The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are zero entries. Non-positive-step probes also measure no-trade stops. Known gaps: a start with positive int evidence and a float step is refused by TradingView; here a narrowed non-positive step stops with RE10021 at bar 0; -the endless own-counter bound hangs here pending a follow-up change; a float +a float counter passed to an untyped helper truncates here while TradingView passes the fractional value (`twice(x)` accumulates 2 here versus 5 on TradingView). `array.from(x)` can also truncate a float counter through inferred `array` diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 8abbb0f5..6d8a0ece 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -233,6 +233,11 @@ def _emit_includes(self, lines: list[str]) -> None: # in favour of fully qualified names emitted at each call site. lines.append("using namespace pineforge;") lines.append("") + from .endless_for import plan_endless_for_loops + self._endless_for_actions = plan_endless_for_loops(self.ctx.ast) + if "stop" in self._endless_for_actions.values(): + lines.append("inline constexpr int kLoopTimeLimitMs = 2000;") + lines.append("") lines.append(RUN_STOP_SHIMS_CPP) self._emit_ta_compat_shims(lines) if getattr(self, "_uses_drawing", False): diff --git a/pineforge_codegen/codegen/endless_for.py b/pineforge_codegen/codegen/endless_for.py new file mode 100644 index 00000000..c70dc3f4 --- /dev/null +++ b/pineforge_codegen/codegen/endless_for.py @@ -0,0 +1,507 @@ +"""Static chart-loop proofs; unproved loops retain their ordinary lowering.""" + +from __future__ import annotations + +from collections import defaultdict +from math import isfinite + +from ..ast_nodes import ( + Assignment, BinOp, BreakStmt, ContinueStmt, ForInStmt, ForStmt, + FuncCall, FuncDef, Identifier, IfStmt, MemberAccess, MethodDef, + NumberLiteral, Program, SwitchStmt, TupleAssign, UnaryOp, VarDecl, WhileStmt, +) +from ..limits import iter_ast_nodes, syntax_children +from .constant_fold import fold_constant_expression +from .tables import ( + ARRAY_JOURNAL_READ_METHODS, MAP_METHODS, MATH_FUNC_MAP, MATRIX_METHODS, + STR_FUNC_MAP, +) +from .types import COLLECTION_MUTATING_METHODS + + +def _affine(node, counter): + """Return (a, c) for a*counter+c, using only literal arithmetic.""" + if isinstance(node, NumberLiteral): + result = (0, node.value) + elif isinstance(node, Identifier) and node.name == counter: + result = (1, 0) + elif isinstance(node, UnaryOp) and node.op in ("+", "-"): + operand = _affine(node.operand, counter) + if operand is None: + return None + result = operand if node.op == "+" else (-operand[0], -operand[1]) + elif isinstance(node, BinOp) and node.op in ("+", "-", "*", "/"): + left = _affine(node.left, counter) + right = _affine(node.right, counter) + if left is None or right is None: + return None + a, c = left + b, d = right + if node.op == "+": + result = (a + b, c + d) + elif node.op == "-": + result = (a - b, c - d) + elif node.op == "*" and not (a and b): + result = (a * d + b * c, c * d) + elif node.op == "/" and b == 0 and d != 0: + result = (a / d, c / d) + else: + return None + else: + return None + return result if all(isfinite(value) for value in result) else None + + +def end_cannot_stop(node: ForStmt) -> bool: + """Prove continuation for both possible initial counter readings. + + Direction is fixed by s versus the end read at counter zero; every + subsequent end reads the new counter. Also checking the start counter + is stricter than that measured rule: both directions must continue. + """ + try: + start = _affine(node.start, None) + step = (0, 1) if node.step is None else _affine(node.step, None) + end = _affine(node.end, node.var) + if start is None or step is None or end is None or step[1] <= 0: + return False + a, c = end + s = start[1] + if a < 1: + return False + at_start = a * s + c + if not isfinite(at_start): + return False + for initial_counter in (0, s): + descending = s > a * initial_counter + c + if not (s >= at_start if descending else s <= at_start): + return False + return True + except (ArithmeticError, ValueError): + # Optional proof: overflow, division by zero and non-finite constants + # must never change an existing loop's emission. + return False + + +class _Bindings: + """Lexical bindings and value/control dependencies, with no branch pruning.""" + + def __init__(self, program): + self.writes = {} + self.chart_loops = [] + self.functions = defaultdict(list) + self.methods = defaultdict(list) + self.references = {} + self.readers = defaultdict(set) + self.read_roots = {} + self.statements = {} + self.top_level = {id(node) for node in program.body} + self.value_users = defaultdict(set) + self.call_effects = {} + self.dependencies = defaultdict(set) + self.effects = defaultdict(set) + self.calls = [] + self._block(program.body, (), owner=None) + self._connect_calls() + + @staticmethod + def _resolve(name, scopes): + for scope in reversed(scopes): + if name in scope: + return scope[name] + return (None, name) + + def _read(self, node, scopes, root): + binding = self._resolve(node.name, scopes) + self.references[id(node)] = binding + self.readers[binding].add(id(node)) + self.read_roots[id(node)] = root + self.dependencies[id(node)].add(binding) + return binding + + def _inputs(self, key, nodes): + inputs = {id(node) for node in nodes if node is not None} + self.dependencies[key].update(inputs) + for value in inputs: + self.value_users[value].add(key) + return inputs + + def _block(self, statements, scopes, *, owner, guards=(), binders=None): + local = dict(binders or {}) + scopes = (*scopes, local) + deferred = [] + result = None + for node in statements: + self.statements[id(node)] = node + if isinstance(node, (FuncDef, MethodDef)): + deferred.append(node) + registry = self.functions if isinstance(node, FuncDef) else self.methods + registry[node.name].append(node) + else: + self._visit(node, scopes, owner=owner, guards=guards) + result = node + # A callable's dependencies become reachable through its calls only. + # Its parameters/locals still shadow chart bindings in this scope walk. + for node in deferred: + # Defaults are stored as annotations, outside syntax_children. + # Count their reads even when the callable is never reached. + for default in (node.annotations or {}).get("param_defaults", ()): + self._visit(default, scopes, owner=id(node)) + params = {name: (id(node), name) for name in node.params} + returned = self._block(node.body, scopes, owner=id(node), binders=params) + self._inputs(id(node), [returned]) + return result + + def _visit(self, node, scopes, *, owner, guards=(), effect_root=None): + if node is None: + return + key = id(node) + if effect_root is None: + effect_root = key + self.dependencies[key].update(guards) + if isinstance(node, Identifier): + self._read(node, scopes, effect_root) + return + if isinstance(node, (VarDecl, TupleAssign)): + self._visit( + node.value, scopes, owner=owner, guards=guards, + effect_root=effect_root, + ) + self._inputs(key, [node.value]) + names = [node.name] if isinstance(node, VarDecl) else node.names + bindings = {(id(node), name) for name in names} + for binding in bindings: + scopes[-1][binding[1]] = binding + self.dependencies[binding].add(key) + if node.value is not None: + self.writes[key] = bindings + if isinstance(node, VarDecl) and (node.is_var or node.is_varip): + self.effects[owner].add(key) + return + if isinstance(node, Assignment): + self._visit( + node.value, scopes, owner=owner, guards=guards, + effect_root=effect_root, + ) + self._inputs(key, [node.value]) + if isinstance(node.target, Identifier): + binding = self._resolve(node.target.name, scopes) + self.writes[key] = {binding} + self.dependencies[binding].add(key) + if node.op != ":=": + self._read(node.target, scopes, key) + self._inputs(key, [node.target]) + else: + self._visit( + node.target, scopes, owner=owner, guards=guards, + effect_root=effect_root, + ) + self._inputs(key, [node.target]) + self.effects[owner].add(key) + return + if isinstance(node, ForStmt): + if owner is None and (node.annotations or {}).get("pine_version") != 5: + self.chart_loops.append(node) + self._visit(node.start, scopes, owner=owner, guards=guards) + counter_scope = (*scopes, {node.var: (id(node), node.var)}) + for expr in (node.end, node.step): + self._visit(expr, counter_scope, owner=owner, guards=guards) + headers = self._inputs(key, [node.start, node.end, node.step]) + self.dependencies[(key, node.var)].update(headers) + returned = self._block( + node.body, counter_scope, owner=owner, guards=(*guards, *headers), + ) + self._inputs(key, [returned]) + return + if isinstance(node, ForInStmt): + self._visit(node.iterable, scopes, owner=owner, guards=guards) + headers = self._inputs(key, [node.iterable]) + names = [node.var] if node.var else (node.vars or []) + binders = {name: (id(node), name) for name in names} + for binding in binders.values(): + self.dependencies[binding].update(headers) + returned = self._block( + node.body, scopes, owner=owner, guards=(*guards, *headers), + binders=binders, + ) + self._inputs(key, [returned]) + return + if isinstance(node, IfStmt): + self._visit(node.condition, scopes, owner=owner, guards=guards) + headers = self._inputs(key, [node.condition]) + for body in (node.body, node.else_body): + returned = self._block( + body, scopes, owner=owner, guards=(*guards, *headers), + ) + self._inputs(key, [returned]) + return + if isinstance(node, SwitchStmt): + conditions = [node.expr, *(condition for condition, _body in node.cases)] + for condition in conditions: + self._visit(condition, scopes, owner=owner, guards=guards) + headers = self._inputs(key, conditions) + for body in [*(body for _condition, body in node.cases), node.default_body]: + returned = self._block( + body, scopes, owner=owner, guards=(*guards, *headers), + ) + self._inputs(key, [returned]) + return + if isinstance(node, WhileStmt): + self._visit(node.condition, scopes, owner=owner, guards=guards) + headers = self._inputs(key, [node.condition]) + returned = self._block( + node.body, scopes, owner=owner, guards=(*guards, *headers), + ) + self._inputs(key, [returned]) + return + children = list(syntax_children(node)) + if isinstance(node, FuncCall): + self.calls.append((node, owner, effect_root)) + if isinstance(node.callee, Identifier): + # A direct callable name does not read a scalar binding; + # a method receiver still does. + children = [child for child in children if child is not node.callee] + self._inputs(key, children) + for child in children: + self._visit( + child, scopes, owner=owner, guards=guards, + effect_root=effect_root, + ) + + def called_helpers(self, call): + helpers = self.functions.get(_spelling(call.callee), ()) + if not helpers and isinstance(call.callee, MemberAccess): + receiver = call.callee.object + if isinstance(receiver, Identifier) and self.references.get( + id(receiver), (None, receiver.name), + )[0] is None: + return () # An unbound namespace is not a method receiver. + # Without a typed method binding, keep every possible overload. + helpers = self.methods.get(call.callee.member, ()) + return helpers + + def _connect_calls(self): + for call, owner, effect_root in self.calls: + if _call_has_effect(call, self): + # An effectful statement reads its whole expression, even + # when the call is nested beside an otherwise unused input. + self.effects[owner].update((id(call), effect_root)) + for call, _owner, _effect_root in self.calls: + for helper in self.called_helpers(call): + self.dependencies[id(call)].add(id(helper)) + self.dependencies[id(call)].update(self.effects[id(helper)]) + + def live_values(self): + """Least fixed point from chart effects through value/control inputs. + + The dead_counter, dead_counter_plus_one, dead_nonzero_start, + dead_counter_with_break and dead_chain fixtures measure unused + reductions. live_single_bar_read and live_untaken_branch_read stop + even for late/untaken effect reads. Retain visual, persistent and + collection effects and every call we cannot prove pure. Self-updates + and break tests have no root of their own. uncalled_function_loop + and function_called_in_dead_branch add no chart roots. Absence of a + path to an effect alone does not establish that omission is measured. + """ + live = set() + pending = list(self.effects[None]) + while pending: + value = pending.pop() + if value not in live: + live.add(value) + pending.extend(self.dependencies.get(value, ())) + return live + + +def _spelling(node): + if isinstance(node, Identifier): + return node.name + if isinstance(node, MemberAccess): + owner = _spelling(node.object) + return f"{owner}.{node.member}" if owner else None + return None + + +def _body_nodes(body): + for statement in body: + for node, _depth in iter_ast_nodes(statement): + yield node + + +def _expanded_body_nodes(body, bindings): + """Include called helpers when checking for an existing run stop.""" + pending = [body] + seen = set() + while pending: + for node in _body_nodes(pending.pop()): + yield node + if isinstance(node, FuncCall): + for helper in bindings.called_helpers(node): + if id(helper) not in seen: + seen.add(id(helper)) + pending.append(helper.body) + + +def _call_has_effect(call, bindings, visiting=frozenset()): + spelling = _spelling(call.callee) + helpers = bindings.functions.get(spelling, ()) + if helpers: + if spelling in visiting: + return True + if spelling in bindings.call_effects: + return bindings.call_effects[spelling] + bindings.call_effects[spelling] = True + for helper in helpers: + for node in _body_nodes(helper.body): + if isinstance(node, VarDecl) and (node.is_var or node.is_varip): + return True + if isinstance(node, Assignment) and not isinstance(node.target, Identifier): + return True + if isinstance(node, FuncCall) and _call_has_effect( + node, bindings, visiting | {spelling}, + ): + return True + bindings.call_effects[spelling] = False + return False + # Purity bounds effect reachability; it does not authorize omission. + # Orders, visual output, drawings, alerts, requests and collection + # mutations keep their inputs live, as do unresolved effects. + if spelling in {"na", "nz", "fixnan", "int", "float", "bool", "string"}: + return False + if isinstance(call.callee, MemberAccess) and isinstance( + call.callee.object, Identifier, + ): + owner = call.callee.object + if bindings.references.get(id(owner), (None, owner.name))[0] is not None: + return True + pure = { + "array": ARRAY_JOURNAL_READ_METHODS, + "matrix": MATRIX_METHODS.keys() - COLLECTION_MUTATING_METHODS, + "map": MAP_METHODS.keys() - COLLECTION_MUTATING_METHODS, + "math": MATH_FUNC_MAP, + "str": STR_FUNC_MAP, + } + return call.callee.member not in pure.get(owner.name, ()) + return True + + +def _reachable_nodes(body): + """Prune only proved constant branches for the break check.""" + for node in body: + yield node + if isinstance(node, IfStmt): + yield from _body_nodes([node.condition]) + constant, proven = fold_constant_expression(node.condition, {}, {}) + if proven and type(constant) is bool: + yield from _reachable_nodes(node.body if constant else node.else_body) + else: + yield from _reachable_nodes(node.body) + yield from _reachable_nodes(node.else_body) + elif isinstance(node, (ForStmt, ForInStmt, WhileStmt)): + if isinstance(node, WhileStmt): + constant, proven = fold_constant_expression(node.condition, {}, {}) + if proven and constant is False: + continue + yield from _reachable_nodes(node.body) + elif isinstance(node, SwitchStmt): + for _condition, statements in node.cases: + yield from _reachable_nodes(statements) + yield from _reachable_nodes(node.default_body) + else: + for child in syntax_children(node): + yield from _reachable_nodes([child]) + if isinstance(node, (BreakStmt, ContinueStmt)): + break + + +def _measured_unused_reduction(loop, nodes, writes, bindings): + """Accept unread updates, optionally feeding one unread declaration. + + Self-updates and the dead_counter_with_break fixture's break condition + are internal reads. Every other read must have the dead_chain fixture's + single declaration edge, whose destination has no readers or reassignments. + Callable/default reads count regardless of whether a call is reachable. + """ + if bindings.value_users.get(id(loop)) or any( + isinstance(node, FuncCall) for node in nodes + ): + return False + internal = {id(node) for node in nodes} + break_reads = { + id(child) + for node in nodes + if isinstance(node, IfStmt) and not node.else_body + and len(node.body) == 1 and isinstance(node.body[0], BreakStmt) + for child in _body_nodes([node.condition]) + } + chain = set() + for binding in writes: + for reader in bindings.readers[binding]: + root = bindings.read_roots[reader] + if reader in internal: + if reader in break_reads or binding in bindings.writes.get(root, ()): + continue + return False + statement = bindings.statements.get(root) + if ( + root not in bindings.top_level + or not isinstance(statement, VarDecl) + or statement.is_var or statement.is_varip + ): + return False + expression = list(_body_nodes([statement.value])) + if any(not isinstance( + node, (Identifier, NumberLiteral, BinOp, UnaryOp), + ) for node in expression): + return False + if sum(isinstance(node, Identifier) for node in expression) != 1: + return False + destination = (root, statement.name) + if bindings.readers[destination] or any( + key != root and destination in targets + for key, targets in bindings.writes.items() + ): + return False + chain.add(root) + if len(chain) > 1: + return False + return True + + +def plan_endless_for_loops(program: Program) -> dict[int, str]: + """Return chart loop actions ('omit' or 'stop'), keyed only for lookup.""" + if program.version == 5 or not any( + isinstance(node, ForStmt) and end_cannot_stop(node) + for node, _depth in iter_ast_nodes(program) + ): + return {} + bindings = _Bindings(program) + live_values = bindings.live_values() + actions = {} + for loop in bindings.chart_loops: + if not end_cannot_stop(loop): + continue + nodes = list(_body_nodes(loop.body)) + # Do not replace a body-provided error, even through a helper. + if any( + isinstance(node, FuncCall) and _spelling(node.callee) == "runtime.error" + for node in _expanded_body_nodes(loop.body, bindings) + ): + continue + writes = set().union(*(bindings.writes.get(id(node), set()) for node in nodes)) + if (id(loop), loop.var) in writes: + continue # A reassigned counter invalidates the monotonic proof. + live = ( + id(loop) in live_values + or bool(writes & live_values) + or any(id(node) in bindings.effects[None] for node in nodes) + ) + if not live and _measured_unused_reduction(loop, nodes, writes, bindings): + # TradingView also drops an unused reduction with a break: no + # result or effect can escape, regardless of when it would end. + actions[id(loop)] = "omit" + elif not live or not any( + isinstance(node, BreakStmt) for node in _reachable_nodes(loop.body) + ): + actions[id(loop)] = "stop" + return actions diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 782eb12c..092956fa 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -1737,6 +1737,20 @@ def _visit_if_body(self, node: IfStmt, lines: list[str], indent: int) -> None: def _visit_for(self, node: ForStmt, lines: list[str], indent: int, value_target: tuple[str, str | None] | None = None) -> None: + action = getattr(self, "_endless_for_actions", {}).get(id(node)) + if action is not None: + # Keep lexical/naming bookkeeping (including nested loop tokens) + # identical for statements after this one; discard only its text. + self._visit_counter_bound_for(node, [], indent, value_target) + if action == "stop": + pad = " " * indent + lines.append( + f'{pad}pine_runtime_error(std::string("Error on bar ") ' + '+ std::to_string(bar_index_) ' + '+ ": Loop takes too long to execute (> " ' + '+ std::to_string(::kLoopTimeLimitMs) + " ms)");' + ) + return if self._for_header_reads_new_counter(node): self._visit_counter_bound_for(node, lines, indent, value_target) return diff --git a/tests/_endless_for_cases.py b/tests/_endless_for_cases.py new file mode 100644 index 00000000..cef54f0b --- /dev/null +++ b/tests/_endless_for_cases.py @@ -0,0 +1,163 @@ +"""Measured loop outcomes and finite/scope controls.""" + +import json +import re +from pathlib import Path +from textwrap import dedent + + +FIXTURES = Path(__file__).parent / "fixtures" / "endless_for" +BASELINE = FIXTURES / "prior_cpp_baseline.json" +TV = json.loads((FIXTURES / "tradingview.json").read_text(encoding="utf-8")) +OMITTED_RUNS = [ + name for name, row in TV["runs"].items() if row.get("loop_action", "omit") == "omit" +] +FUNCTION_RUNS = [ + name for name, row in TV["runs"].items() if row.get("loop_action") == "unchanged" +] + + +def source(body, tail="plot(acc)\n"): + return ( + '//@version=6\nstrategy("Loop bounds", currency=currency.NONE)\n' + + dedent(body).lstrip() + tail + ) + + +LIVE_REACHABILITY_CONTROLS = { + "chain_to_order": source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + float chained = acc + 1 + """, 'strategy.entry("L", strategy.long, qty=chained)\n'), + "condition_to_order": source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + float chained = acc + 1 + if chained > 0 + strategy.entry("L", strategy.long, qty=1) + """, ""), +} + + +CONTROLS = { + "fixed_end": source(""" + float acc = 0.0 + for walkIndex = 0 to 10 + acc += 1 + """), + "changing_ternary": source(""" + float acc = 0.0 + for walkIndex = 0 to (walkIndex == 0 ? 3 : 1) + acc += walkIndex + 1 + """), + "slope_below_one": source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex / 2 + 5 + acc += 1 + """), + "first_read_disagreement": source(""" + float acc = 0.0 + for walkIndex = -20 to walkIndex - 5 + acc += 1 + """), + "reachable_break": source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + if acc > 1000 + break + """), + "function": source(""" + measure() => + float subtotal = 0.0 + for walkIndex = 0 to walkIndex + subtotal += 1 + subtotal + float acc = measure() + """), + "method": source(""" + method measure(float sampleValue) => + float subtotal = sampleValue + for walkIndex = 0 to walkIndex + subtotal += 1 + subtotal + float initialValue = 0.0 + float acc = initialValue.measure() + """), + "security_helper": source(""" + measure() => + float subtotal = 0.0 + for walkIndex = 0 to walkIndex + subtotal += 1 + subtotal + float acc = request.security(syminfo.tickerid, timeframe.period, measure()) + """), + "variable_coefficient": source(""" + slope = input.float(1.0) + float acc = 0.0 + for walkIndex = 0 to slope * walkIndex + acc += 1 + """), + "variable_start": source(""" + first = input.int(0) + float acc = 0.0 + for walkIndex = first to walkIndex + acc += 1 + """), + "variable_step": source(""" + stride = input.int(1) + float acc = 0.0 + for walkIndex = 0 to walkIndex by stride + acc += 1 + """), + "nonpositive_step": source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex by 0 + acc += 1 + """), + "runtime_error": source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + runtime.error("Stopped by the body") + acc += 1 + """), +} + + +LATER_LOOP_TARGETS = { + "later_finite_loop_live": "acc", + "later_finite_loop_dead": "unusedTotal", +} +LATER_LOOP_SOURCES = { + name: source( + "float acc = 0.0\n" + "float unusedTotal = 0.0\n" + "for counterValue = 0 to counterValue\n" + f" {target} += 1\n" + "for controlIndex = 0 to 10\n" + " acc += controlIndex\n", + ) + for name, target in LATER_LOOP_TARGETS.items() +} + +# Freeze the exact inputs shared by the comparison tests and baseline capture. +BASELINE_SOURCES = { + **{ + name: (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") + for name in FUNCTION_RUNS + }, + **CONTROLS, + **LATER_LOOP_SOURCES, +} + +# Keep the whole later loop, including its saved bounds, step and indentation. +LATER_FINITE_LOOP = re.compile( + r"^(?P[ \t]*)int _for_start_1 = [^\n]*;\n" + r".*?^(?P=indent)for \(int controlIndex = [^\n]*\) \{\n" + r"[ \t]+acc \+= controlIndex;\n" + r"(?P=indent)\}\n", + re.MULTILINE | re.DOTALL, +) diff --git a/tests/fixtures/endless_for/dead_chain.pine b/tests/fixtures/endless_for/dead_chain.pine new file mode 100644 index 00000000..3fcc4187 --- /dev/null +++ b/tests/fixtures/endless_for/dead_chain.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Endless loop whose result is read only by another unused variable", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + acc := acc + 1 +float chained = acc + 1 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("M", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close_all() diff --git a/tests/fixtures/endless_for/dead_counter.pine b/tests/fixtures/endless_for/dead_counter.pine new file mode 100644 index 00000000..07460eba --- /dev/null +++ b/tests/fixtures/endless_for/dead_counter.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Loop end bound is the counter, no break", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + acc := acc + 1 +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/endless_for/dead_counter_plus_one.pine b/tests/fixtures/endless_for/dead_counter_plus_one.pine new file mode 100644 index 00000000..458a185d --- /dev/null +++ b/tests/fixtures/endless_for/dead_counter_plus_one.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Loop end bound is the counter plus one, no break", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + 1 + acc := acc + 1 +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/endless_for/dead_counter_with_break.pine b/tests/fixtures/endless_for/dead_counter_with_break.pine new file mode 100644 index 00000000..ec5a1797 --- /dev/null +++ b/tests/fixtures/endless_for/dead_counter_with_break.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Loop end bound is the counter", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + acc := acc + 1 + if acc > 1000000 + break +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/endless_for/dead_nonzero_start.pine b/tests/fixtures/endless_for/dead_nonzero_start.pine new file mode 100644 index 00000000..5fdad8c2 --- /dev/null +++ b/tests/fixtures/endless_for/dead_nonzero_start.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Descending loop end bound is the counter, no break", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 5 to i + acc := acc + 1 +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/endless_for/function_called_in_dead_branch.pine b/tests/fixtures/endless_for/function_called_in_dead_branch.pine new file mode 100644 index 00000000..84810c87 --- /dev/null +++ b/tests/fixtures/endless_for/function_called_in_dead_branch.pine @@ -0,0 +1,13 @@ +//@version=6 +strategy("Endless loop inside a function called only in a branch never taken", initial_capital = 1000000, currency = currency.NONE) +spin() => + float acc = 0.0 + for i = 0 to i + acc := acc + 1 + acc +if bar_index < 0 + strategy.entry("N", strategy.long, qty = 2 + spin() * 0) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("M", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close_all() diff --git a/tests/fixtures/endless_for/live_counter.pine b/tests/fixtures/endless_for/live_counter.pine new file mode 100644 index 00000000..a2a5ea24 --- /dev/null +++ b/tests/fixtures/endless_for/live_counter.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Loop end bound is the counter, iteration count in the quantity", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + acc := acc + 1 +// quantity: 1 + the number of iterations when it is at most 20, otherwise 21 + the number of powers of ten from 1e3 up to 1e8 that the count reaches +float shown = acc <= 20 ? acc : 20 + (acc >= 1e3 ? 1 : 0) + (acc >= 1e4 ? 1 : 0) + (acc >= 1e5 ? 1 : 0) + (acc >= 1e6 ? 1 : 0) + (acc >= 1e7 ? 1 : 0) + (acc >= 1e8 ? 1 : 0) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = shown + 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/endless_for/live_counter_plus_one.pine b/tests/fixtures/endless_for/live_counter_plus_one.pine new file mode 100644 index 00000000..2c6499da --- /dev/null +++ b/tests/fixtures/endless_for/live_counter_plus_one.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Loop end bound is the counter plus one, iteration count in the quantity", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + 1 + acc := acc + 1 +// quantity: 1 + the number of iterations when it is at most 20, otherwise 21 + the number of powers of ten from 1e3 up to 1e8 that the count reaches +float shown = acc <= 20 ? acc : 20 + (acc >= 1e3 ? 1 : 0) + (acc >= 1e4 ? 1 : 0) + (acc >= 1e5 ? 1 : 0) + (acc >= 1e6 ? 1 : 0) + (acc >= 1e7 ? 1 : 0) + (acc >= 1e8 ? 1 : 0) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = shown + 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/endless_for/live_nonzero_start.pine b/tests/fixtures/endless_for/live_nonzero_start.pine new file mode 100644 index 00000000..9cff7653 --- /dev/null +++ b/tests/fixtures/endless_for/live_nonzero_start.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Descending loop end bound is the counter, iteration count in the quantity", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 5 to i + acc := acc + 1 +// quantity: 1 + the number of iterations when it is at most 20, otherwise 21 + the number of powers of ten from 1e3 up to 1e8 that the count reaches +float shown = acc <= 20 ? acc : 20 + (acc >= 1e3 ? 1 : 0) + (acc >= 1e4 ? 1 : 0) + (acc >= 1e5 ? 1 : 0) + (acc >= 1e6 ? 1 : 0) + (acc >= 1e7 ? 1 : 0) + (acc >= 1e8 ? 1 : 0) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = shown + 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/endless_for/live_single_bar_read.pine b/tests/fixtures/endless_for/live_single_bar_read.pine new file mode 100644 index 00000000..74732932 --- /dev/null +++ b/tests/fixtures/endless_for/live_single_bar_read.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Endless loop whose result is used only at one bar", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + acc := acc + 1 +if bar_index == 100 + strategy.entry("L", strategy.long, qty = 2 + acc * 0) +if bar_index > 6 and bar_index % 7 == 3 and bar_index != 100 + strategy.entry("M", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close_all() diff --git a/tests/fixtures/endless_for/live_untaken_branch_read.pine b/tests/fixtures/endless_for/live_untaken_branch_read.pine new file mode 100644 index 00000000..09697cda --- /dev/null +++ b/tests/fixtures/endless_for/live_untaken_branch_read.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Endless loop whose result is used only in a branch never taken", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to i + acc := acc + 1 +if bar_index < 0 + strategy.entry("N", strategy.long, qty = 2 + acc * 0) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("M", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close_all() diff --git a/tests/fixtures/endless_for/prior_cpp_baseline.json b/tests/fixtures/endless_for/prior_cpp_baseline.json new file mode 100644 index 00000000..67f80b43 --- /dev/null +++ b/tests/fixtures/endless_for/prior_cpp_baseline.json @@ -0,0 +1,76 @@ +{ + "schema": 1, + "provenance": "captured from the counted-loop lowering that precedes the endless-loop rule", + "sources": { + "changing_ternary": { + "source_sha256": "51d1fe3beccca267671294c0ee9202dcb2cb0a50f86fc069a0cd5d1ad4ac928e", + "cpp_sha256": "994d33d444172510fac555eab1f58d1e30a41f3e3ac88ef85d89a44fcb57f213" + }, + "first_read_disagreement": { + "source_sha256": "a63e2222b1e5c1f174477dd2d816eebbf6b4e57cec4996ec93fd22cfa41ceaaa", + "cpp_sha256": "d02d3a88f3aedfde33cc1740457cd381e7abaaa69f303eec2ad5fb22f8fe129f" + }, + "fixed_end": { + "source_sha256": "24c12ddfef572c7809af44a1d59b9a2863e4cc36d4373849a138c431550d8b42", + "cpp_sha256": "7488c4f77510ea42c0de678a6e46010b56468897dadb3867ef856ebcf0b2fd2a" + }, + "function": { + "source_sha256": "e8f29346c0a9543c7c2c2ea8066013ff3145643e37858db11291b98fee0d61a4", + "cpp_sha256": "bf880d510a43cf3a93767d93c07760f66ad9ef9ca502d36d4589104162a50885" + }, + "function_called_in_dead_branch": { + "source_sha256": "4cb393035849b04cabe05c43f35b33e6ed891be23a75e1f8b39585c8a5841835", + "cpp_sha256": "548a0762697a792d5d03c6ccfb2ef5ed85eaf39611c40e513290d078a3225de5" + }, + "later_finite_loop_dead": { + "source_sha256": "c35df97e8ebd2bf700c59072d5ccb64ce991b821a29a1841fa9b9549884129f7", + "cpp_sha256": "7b60ed05703b4f0a5fb922a25efad2c6ddd4168d932fb39ade209505f66ca49b", + "later_loop_sha256": "3133f6925afd140ecf527d41bf7262f00a339898b0e0d587d9a9d4cabd317465" + }, + "later_finite_loop_live": { + "source_sha256": "ef366357ce3ed80cc2446b0a0853722f46fc7e820f33ac5890454b1f3e8c0fff", + "cpp_sha256": "4482201fcf5d0a1a3aadcd2ced76b462ade643d75e6b5b0afe8376cebc5fca38", + "later_loop_sha256": "3133f6925afd140ecf527d41bf7262f00a339898b0e0d587d9a9d4cabd317465" + }, + "method": { + "source_sha256": "536e89633b9016ed1a0d634d6ca285e711608ef5761e50209ef532b8fe9c1fa6", + "cpp_sha256": "69417a79f57b0703141ebe0acd7ba8950906fc0025e4b77bb3d141526aca5983" + }, + "nonpositive_step": { + "source_sha256": "85a3757a1fd691627511c8d0f21276a5351fa80cfb1ce5a9c00904c1d961bf15", + "cpp_sha256": "f1bd45560e0d97d79ff273c2d23c7a23dcf2ca53dd1ac9b3db6e8512e08faeff" + }, + "reachable_break": { + "source_sha256": "b512a0b6e773d920e44b6061bad958c753eaf9ac08c22f6fc4839a6e804ac563", + "cpp_sha256": "16d5d0925211e04317a7401a823a5f3fca6de13faf782fa577b448097ee1ffec" + }, + "runtime_error": { + "source_sha256": "a8b75024254174d2394b494dbf4aa935e6cd0bfd9ad9c1faf90a5f25cf0b5ed0", + "cpp_sha256": "a5796ee9e1118c6ea0c36a2516149f0b6dd1f8b9ebe563e687aa517898e587ee" + }, + "security_helper": { + "source_sha256": "7e5ceefed80f96e04d678d284d067d8ddfbe24f55fb099cc9cf86f3b29e18e1d", + "cpp_sha256": "af6ba1b5f4f8c92ac0cbe164780231ca4ad7a9e79df03ead49c2951e4c7a4bfa" + }, + "slope_below_one": { + "source_sha256": "708da5466d932d512a70b894e54884e1be824ac5b83853d089bbd032c3324833", + "cpp_sha256": "e8f4f6bc4fdff6fbf32ea5ab57e19cdc7b4b0472138e0348db9788a5a6cf6371" + }, + "uncalled_function_loop": { + "source_sha256": "897669b6b774688ce1de81365ec561af584ca71a0c5c1724aa8bbb1ab7f194d7", + "cpp_sha256": "b873feec1a2637493e9f3eaf4a858a28f927d4bd0a38526ea1c79c2929da8777" + }, + "variable_coefficient": { + "source_sha256": "4400f2340364381505b43255415cfd032fa414ab649c2466d5f64892a2c2e9e6", + "cpp_sha256": "e00803d5ee591dba4a68f3ee01ef002d09d11e8934d565b3f4097508e3ffe7d3" + }, + "variable_start": { + "source_sha256": "3d13d6340d17f41471164f3a60af57f6448169813096dfc557883f1889a42afc", + "cpp_sha256": "4cbb9dedd052ca5fcde2ff384c4ddf7bbeb0d649ebb86a515cf27f049ad3e98c" + }, + "variable_step": { + "source_sha256": "6518d341d2f876dc2ea771b20362303403577176892b9d5f6cdb5af5d632207a", + "cpp_sha256": "432728c9c950ee8c866fd89ea5358d6b7b2bb7353e0d1a6d36760fc23a41d619" + } + } +} diff --git a/tests/fixtures/endless_for/tradingview.json b/tests/fixtures/endless_for/tradingview.json new file mode 100644 index 00000000..663a9df0 --- /dev/null +++ b/tests/fixtures/endless_for/tradingview.json @@ -0,0 +1,82 @@ +{ + "schema": 1, + "runs": { + "dead_counter_with_break": { + "entries": 205, + "quantity": 1, + "tape_sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b", + "source_sha256": "3105568893ed044d323ae64126aa5b40d6446a58e7416eef0682c3a44f26b4ad" + }, + "dead_counter": { + "entries": 205, + "quantity": 1, + "tape_sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b", + "source_sha256": "eb8336c39c93a1d0f4acc0f222fb06a4fce41ea8803adea9ca58a6b802087cbb" + }, + "dead_counter_plus_one": { + "entries": 205, + "quantity": 1, + "tape_sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b", + "source_sha256": "babe3a410d9e98f45d5e165be60e57732e1251718255828551e272d58741e84a" + }, + "dead_nonzero_start": { + "entries": 205, + "quantity": 1, + "tape_sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b", + "source_sha256": "251870f3004e4f79e79698d380d9598d7d580b8ebbdcf57b3c490bc4efba9ed9" + }, + "dead_chain": { + "entries": 205, + "quantity": 1, + "tape_sha256": "767e24ad6352fc4688d73b0e2f7578793fa4ea9b2e7c2251953eccf14ae7fff3", + "source_sha256": "565e7fb7e3e92e4e1ae4cdb99a4c07b33e346c386912861c9b58d2302e9ebb08" + }, + "uncalled_function_loop": { + "entries": 205, + "quantity": 1, + "loop_action": "unchanged", + "tape_sha256": "767e24ad6352fc4688d73b0e2f7578793fa4ea9b2e7c2251953eccf14ae7fff3", + "source_sha256": "897669b6b774688ce1de81365ec561af584ca71a0c5c1724aa8bbb1ab7f194d7" + }, + "function_called_in_dead_branch": { + "entries": 205, + "quantity": 1, + "loop_action": "unchanged", + "tape_sha256": "767e24ad6352fc4688d73b0e2f7578793fa4ea9b2e7c2251953eccf14ae7fff3", + "source_sha256": "4cb393035849b04cabe05c43f35b33e6ed891be23a75e1f8b39585c8a5841835" + } + }, + "stops": { + "live_counter": { + "error_sha256": "34d754a874f4b0c659308818fb6c8f65a397303e94ced0941fd759ce3659fdb8", + "source_sha256": "0a8a0fd92eea7baf7e34aa4be7dc486f18bad7ab8ecd5374857e58219a5b0d1f" + }, + "live_counter_plus_one": { + "error_sha256": "ddcec2bd7880c3c4599d1d2690dbea81823fa336a7fce8a0d1549af4c88d1a36", + "source_sha256": "3dd939191d0787cd45305b5c825a5eead5a10d64287f3e98be8919b2de8283f8" + }, + "live_nonzero_start": { + "error_sha256": "c4a5e68b96acd8290564d5acfd2e4097090cd969cdd27499cd8b5fb760ffe8ae", + "source_sha256": "aa31b1c392c0e99dd1a1cb42968c783b63bb69b28959b4003e9da23ae3df8635" + }, + "live_single_bar_read": { + "error_sha256": "2c776ec50af88b01f9a60748ff33dea7d47c679714fe08a8c0fea2de75a7984c", + "source_sha256": "ad36354ca9d6bd3e55d56577ff774d3a7115928b4d83516c4c18e1bf5336f142" + }, + "live_untaken_branch_read": { + "error_sha256": "a140213d4d8d9b97fe01bb6284cd660d8d1e81815fd28ec1052f3bf97fc2c855", + "source_sha256": "b5821f6944dc363aec5e3afc74675fbc7ce1c688fdcc1b8f55a3097c8e0d10a7" + } + }, + "stop": { + "code": "RE10022", + "bar_index": 0, + "maxTimeMs": 2000, + "message": "Error on bar {bar_index}: Loop takes too long to execute (> {maxTimeMs} ms)" + }, + "break_control": { + "end_bounds": ["i", "i + 1", "i - 1"], + "break_at": 100000, + "entry_quantity": 1.1 + } +} diff --git a/tests/fixtures/endless_for/uncalled_function_loop.pine b/tests/fixtures/endless_for/uncalled_function_loop.pine new file mode 100644 index 00000000..7ff0a360 --- /dev/null +++ b/tests/fixtures/endless_for/uncalled_function_loop.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Endless loop inside a function that is never called", initial_capital = 1000000, currency = currency.NONE) +spin() => + float acc = 0.0 + for i = 0 to i + acc := acc + 1 + acc +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("M", strategy.long, qty = 1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close_all() diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 148cd1c8..17de2e36 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -675,15 +675,16 @@ def test_counted_loop_dynamic_end_counter_shadows_outer_same_named_series(): "i = close + 2\n" "prior = lag(i)\n" "total = 0.0\n" - "for i = 0 to i\n" + "for i = 0 to 10 - i\n" " total += i\n" "plot(prior + total)" ) # TV probes a1/a2/a3 show that the counter shadows an outer binding in # both end and step. The outer series remains available outside the loop. + # The decreasing end keeps this shadowing probe finite. assert "int i = 0;" in cpp - assert "int _for_end_0 = (i);" in cpp - assert "_for_end_0 = (i)" in cpp + assert "int _for_end_0 = ((10 - i));" in cpp + assert ", _for_end_0 = ((10 - i))) {" in cpp assert "_for_end_eval_0" not in cpp compile_cpp(cpp, label="counted-loop-dynamic-end-counter-shadows-series") diff --git a/tests/test_e2e_for_endless_loops.py b/tests/test_e2e_for_endless_loops.py new file mode 100644 index 00000000..6c73bb9a --- /dev/null +++ b/tests/test_e2e_for_endless_loops.py @@ -0,0 +1,114 @@ +"""Native runs reproduce TradingView's loop stops and dead-loop quantities.""" + +from __future__ import annotations + +import csv + +import pytest + +from pineforge_codegen import transpile +from tests import _compile as compile_env +from tests._e2e import Build, chart_feed_head, execute_all, ok, skip_unless_e2e_env +from tests._endless_for_cases import ( + FIXTURES, LIVE_REACHABILITY_CONTROLS, OMITTED_RUNS, TV, source, +) + + +STOP_DRIVER = r''' +#include +#include +int main() { + Bar bar{10.0, 10.0, 10.0, 10.0, 1.0, 60000}; + GeneratedStrategy strategy; + strategy.run(&bar, 1, "1", "1"); + assert(strategy.last_error() == "@EXPECTED@"); + assert(strategy.trade_count() == 0); + std::cout << "stopped before trades"; +} +''' + + +@pytest.mark.parametrize("name", TV["stops"]) +def test_used_endless_loop_stops_at_bar_zero_without_trades(name): + compile_env.skip_if_no_engine_lib() + pine = (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") + cpp = transpile(pine) + # Fail before executing an unchanged translator's endless loop. + assert "Loop takes too long to execute" in cpp + expected = TV["stop"]["message"].format(**TV["stop"]) + assert compile_env.run_emitted_tu( + cpp, STOP_DRIVER.replace("@EXPECTED@", expected), opt="-O2", label=name, + ) == "stopped before trades" + + +@pytest.mark.parametrize("name", LIVE_REACHABILITY_CONTROLS) +def test_transitive_order_dependencies_stop_at_bar_zero_without_trades(name): + compile_env.skip_if_no_engine_lib() + cpp = transpile(LIVE_REACHABILITY_CONTROLS[name]) + assert "Loop takes too long to execute" in cpp + expected = TV["stop"]["message"].format(**TV["stop"]) + assert compile_env.run_emitted_tu( + cpp, STOP_DRIVER.replace("@EXPECTED@", expected), opt="-O2", label=name, + ) == "stopped before trades" + + +@pytest.fixture(scope="module") +def clean_runs(tmp_path_factory): + engine = skip_unless_e2e_env() + base = tmp_path_factory.mktemp("dead_for") + feed = chart_feed_head(engine, base, 1442) + builds = {} + for name in TV["runs"]: + pine = (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") + cpp = transpile(pine) + assert "Loop takes too long" not in cpp + if name in OMITTED_RUNS: + # Fail promptly before running an unmodified endless chart loop. + assert "_for_start_" not in cpp + builds[name] = Build(pine) + return execute_all(engine, feed, base, builds) + + +@pytest.mark.parametrize("name", TV["runs"]) +def test_dead_chart_or_uncalled_function_loop_runs_the_measured_schedule(clean_runs, name): + # Includes a function call in a branch that is never taken. + outcome = ok(clean_runs, name) + rows = csv.DictReader(outcome.trades["default"].decode("utf-8-sig").splitlines()) + entries = [row for row in rows if row["Type"].startswith("Entry")] + assert len(entries) == TV["runs"][name]["entries"] + assert {float(row["Qty"]) for row in entries} == {TV["runs"][name]["quantity"]} + + +@pytest.fixture(scope="module") +def break_runs(tmp_path_factory): + engine = skip_unless_e2e_env() + base = tmp_path_factory.mktemp("for_with_break") + # The first closed entry of the measured schedule is filled by bar 13. + feed = chart_feed_head(engine, base, 14) + builds = {} + for end in TV["break_control"]["end_bounds"]: + pine = source( + "float acc = 0.0\n" + f"for i = 0 to {end}\n" + " acc += 1.0\n" + f' if acc >= {TV["break_control"]["break_at"]}.0\n' + " break\n", + 'if bar_index > 6 and bar_index % 7 == 3\n' + ' strategy.entry("L", strategy.long, qty=1.0 + acc / 1000000.0)\n' + 'if bar_index > 6 and bar_index % 7 == 5\n' + ' strategy.close("L")\n', + ) + cpp = transpile(pine) + assert "Loop takes too long" not in cpp + assert "_for_start_" in cpp + builds[end] = Build(pine) + return execute_all(engine, feed, base, builds) + + +@pytest.mark.parametrize("end", TV["break_control"]["end_bounds"]) +def test_reachable_break_keeps_the_measured_iteration_count(break_runs, end): + outcome = ok(break_runs, end) + rows = csv.DictReader(outcome.trades["default"].decode("utf-8-sig").splitlines()) + entries = [row for row in rows if row["Type"].startswith("Entry")] + assert len(entries) == 1 + assert float(entries[0]["Qty"]) == TV["break_control"]["entry_quantity"] diff --git a/tests/test_for_endless_loops.py b/tests/test_for_endless_loops.py new file mode 100644 index 00000000..74eea2d4 --- /dev/null +++ b/tests/test_for_endless_loops.py @@ -0,0 +1,393 @@ +"""TradingView omits unused loops and stops used endless loops at bar zero.""" + +from __future__ import annotations + +import hashlib +import json +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.codegen.endless_for import plan_endless_for_loops +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.parser import Parser +from tests._endless_for_cases import ( + BASELINE, BASELINE_SOURCES, CONTROLS, FIXTURES, FUNCTION_RUNS, + LATER_FINITE_LOOP, LATER_LOOP_SOURCES, LATER_LOOP_TARGETS, + LIVE_REACHABILITY_CONTROLS, OMITTED_RUNS, TV, source, +) + + +def _assert_stop(cpp): + assert '"Error on bar "' in cpp + assert '": Loop takes too long to execute (> "' in cpp + assert '" ms)"' in cpp + assert re.search(r"constexpr int kLoopTimeLimitMs = 2000;", cpp) + assert "std::to_string(::kLoopTimeLimitMs)" in cpp + assert "pine_runtime_error(" in cpp + assert "_for_start_" not in cpp + + +@pytest.mark.parametrize("name", [*TV["runs"], *TV["stops"]]) +def test_fixtures_are_the_measured_sources(name): + evidence = TV["runs"].get(name, TV["stops"].get(name)) + assert hashlib.sha256((FIXTURES / f"{name}.pine").read_bytes()).hexdigest() == ( + evidence["source_sha256"] + ) + digest = evidence.get("tape_sha256", evidence.get("error_sha256")) + assert re.fullmatch(r"[0-9a-f]{64}", digest) + + +@pytest.mark.parametrize("name", OMITTED_RUNS) +def test_tradingview_omits_the_unused_loop(name): + # TradingView runs these with 205 entries, each with quantity 1. + cpp = transpile((FIXTURES / f"{name}.pine").read_text(encoding="utf-8")) + # A receiving declaration can remain, but the loop's update cannot. + assert not re.search(r"\bacc\s*=\s*\(acc \+ 1\)", cpp) + assert "_for_start_" not in cpp + assert "Loop takes too long" not in cpp + assert re.search(r"\bacc\b.*0\.0", cpp) + + +@pytest.mark.parametrize("name", TV["stops"]) +def test_tradingview_stops_the_used_loop_even_for_late_or_untaken_reads(name): + # TradingView stops on bar 0 with RE10022, maxTimeMs=2000. + cpp = transpile((FIXTURES / f"{name}.pine").read_text(encoding="utf-8")) + _assert_stop(cpp) + assert "acc + 1" not in cpp + + +@pytest.fixture(scope="module") +def prior_cpp_hashes(): + assert BASELINE.is_file(), ( + f"Missing prior counted-loop C++ baseline: {BASELINE}. " + "Capture it from the counted-loop lowering that precedes the endless-loop rule." + ) + document = json.loads(BASELINE.read_text(encoding="utf-8")) + assert document.get("schema") == 1, "Expected prior C++ baseline schema 1" + rows = document.get("sources", {}) + missing = set(BASELINE_SOURCES) - set(rows) + unexpected = set(rows) - set(BASELINE_SOURCES) + assert not missing and not unexpected, ( + f"Prior C++ baseline source inventory mismatch: " + f"missing={sorted(missing)}, unexpected={sorted(unexpected)}" + ) + for name, pine in BASELINE_SOURCES.items(): + row = rows[name] + keys = ["source_sha256", "cpp_sha256"] + if name in LATER_LOOP_SOURCES: + keys.append("later_loop_sha256") + for key in keys: + assert re.fullmatch(r"[0-9a-f]{64}", row.get(key, "")), ( + f"Missing or invalid {key} for baseline source {name}" + ) + assert hashlib.sha256(pine.encode("utf-8")).hexdigest() == row["source_sha256"], ( + f"Baseline source has changed: {name}" + ) + return rows + + +def _assert_prior_cpp(name, prior_cpp_hashes): + cpp = transpile(BASELINE_SOURCES[name]) + assert hashlib.sha256(cpp.encode("utf-8")).hexdigest() == ( + prior_cpp_hashes[name]["cpp_sha256"] + ), f"C++ differs from the prior counted-loop lowering: {name}" + return cpp + + +@pytest.mark.parametrize("name", FUNCTION_RUNS) +def test_function_body_loops_keep_the_prior_cpp_and_no_translation_stop( + name, prior_cpp_hashes, +): + cpp = _assert_prior_cpp(name, prior_cpp_hashes) + assert "Loop takes too long" not in cpp + + +@pytest.mark.parametrize("name", CONTROLS) +def test_out_of_class_loops_keep_every_cpp_byte( + name, prior_cpp_hashes, +): + _assert_prior_cpp(name, prior_cpp_hashes) + + +@pytest.mark.parametrize("header", [ + "0 to walkIndex", + "0 to walkIndex - 1", + "5 to walkIndex", + "0 to 2 * walkIndex + 3", + "0.0 to walkIndex + 2.5 by (1.0 / 4)", + "(3 - 3) to ((2 + 2) / 2) * walkIndex + (9 - 6) by (4 - 2)", +]) +def test_affine_rule_is_independent_of_counter_spelling_and_constant_syntax(header): + # These are consequences of the measured fixed-direction continuation rule. + pine = source( + f"float acc = 0.0\nfor walkIndex = {header}\n acc += 1\n", + ) + _assert_stop(transpile(pine)) + + +@pytest.mark.parametrize("tail", [ + "plot(acc)\n", + "firstCopy = acc + 1\nsecondCopy = firstCopy + 1\nplot(secondCopy)\n", + "measure() => acc\nplot(measure())\n", + "unusedCopy = acc + math.random(0.0, 1.0)\n", +]) +def test_a_transitive_path_to_an_effect_keeps_the_loop_live(tail): + _assert_stop(transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + """, tail))) + + +@pytest.mark.parametrize("name", LIVE_REACHABILITY_CONTROLS) +def test_a_chain_reaching_order_data_or_its_condition_keeps_the_loop_live(name): + _assert_stop(transpile(LIVE_REACHABILITY_CONTROLS[name])) + + +@pytest.mark.parametrize("tail", [ + "firstCopy = acc + 1\nsecondCopy = firstCopy + 1\n", + "firstCopy = acc + 1\nsecondCopy = 0.0\nsecondCopy := firstCopy\n", + "measure() => acc\nunusedCopy = measure()\n", + "measure() => acc\n", + "measure() =>\n unusedCopy = acc + 1\n 1.0\nplot(measure())\n", + "if acc > 0\n unusedCopy = acc + 1\n", + "firstCopy = 0.0\nfirstCopy := acc + 1\n", + "unusedCopy = acc + 1\nunusedCopy := 0.0\n", + "firstCopy = acc + 1\nsecondCopy = acc + 2\n", +]) +def test_unmeasured_reads_stop_even_without_an_effect(tail): + cpp = transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + """, tail)) + _assert_stop(cpp) + + +def test_a_parameter_shadow_does_not_read_the_written_binding(): + # Only the separate parameter binding is read; the chart write is unread. + cpp = transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + """, "measure(float acc) => acc + 1\nplot(measure(1.0))\n")) + assert "_for_start_" not in cpp + assert "Loop takes too long" not in cpp + + +@pytest.mark.parametrize("tail", [ + "measure(float sampleValue = acc) => sampleValue\n", + "method measure(float sampleValue, float increment = acc) => sampleValue + increment\n", + "unusedCopy = acc + 1\nmeasure(float sampleValue = unusedCopy) => sampleValue\n", +]) +def test_default_reads_preclude_omission_before_support_checks(tail): + # Defaults are annotation nodes. Check the planner without claiming that + # TradingView accepts a variable in a parameter default. + program = Parser(Lexer(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + """, tail)).tokenize()).parse() + assert set(plan_endless_for_loops(program).values()) == {"stop"} + + +@pytest.mark.parametrize("tail", [ + 'switch\n acc > 0 => strategy.entry("L", strategy.long, qty=1)\n', + 'for controlIndex = 0 to int(acc)\n' + ' strategy.entry("L", strategy.long, qty=1)\n', + 'while acc > 0\n strategy.entry("L", strategy.long, qty=1)\n break\n', + 'float quantityValue = 1.0\nif acc > 0\n quantityValue := 2.0\n' + 'strategy.entry("L", strategy.long, qty=quantityValue)\n', +]) +def test_control_headers_reach_an_effect_or_a_live_definition(tail): + cpp = transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + """, tail)) + assert "Loop takes too long to execute" in cpp + assert "int walkIndex =" not in cpp + + +@pytest.mark.parametrize("body", [ + 'strategy.entry("L", strategy.long, qty=1)', + "array.push(values, float(walkIndex))", + 'alert("Loop body")', + "label.new(walkIndex, close)", + "var float savedValue = 0.0", + "array.push(values, 1.0)", +]) +def test_body_effects_keep_the_loop_live_without_a_scalar_reader(body): + pine = source( + "values = array.new_float()\n" + "for walkIndex = 0 to walkIndex\n" + f" {body}\n", + "", + ) + _assert_stop(transpile(pine)) + + +@pytest.mark.parametrize("callee", ["unknownCall", "array.unknownCall", "math.unknownCall"]) +def test_a_call_without_a_purity_proof_is_an_effect(callee): + # Exercise the planner before the support checker rejects unknown calls. + program = Parser(Lexer(source( + "for walkIndex = 0 to walkIndex\n" + f" {callee}(walkIndex)\n", + "", + )).tokenize()).parse() + assert set(plan_endless_for_loops(program).values()) == {"stop"} + + +def test_an_unknown_effect_in_an_unused_statement_keeps_its_other_inputs_live(): + program = Parser(Lexer(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + unusedCopy = acc + unknownCall() + """, "")).tokenize()).parse() + assert set(plan_endless_for_loops(program).values()) == {"stop"} + + +def test_pure_math_and_string_calls_in_an_unused_loop_stop(): + cpp = transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += math.abs(walkIndex) + rendered = str.tostring(walkIndex) + """, "")) + _assert_stop(cpp) + + +def test_an_unmeasured_unused_body_stops_even_with_a_break(): + _assert_stop(transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += math.abs(walkIndex) + if acc > 10 + break + """, ""))) + + +def test_loop_expression_result_is_live(): + # A value-producing if can return its final loop's value. + _assert_stop(transpile(source(""" + acc = if true + for walkIndex = 0 to walkIndex + walkIndex + 1 + """))) + + +def test_an_unused_loop_expression_result_stops(): + cpp = transpile(source(""" + unusedTotal = if true + for walkIndex = 0 to walkIndex + walkIndex + 1 + """, "plot(close)\n")) + _assert_stop(cpp) + + +def test_nested_chart_loop_is_classified_at_its_own_position(): + cpp = transpile(source(""" + float acc = 0.0 + if bar_index >= 0 + for outerIndex = 0 to 1 + for walkIndex = 0 to walkIndex + acc += 1 + """)) + assert "Loop takes too long" in cpp + assert cpp.count("constexpr int kLoopTimeLimitMs = 2000;") == 1 + assert re.search(r"for \(int outerIndex =", cpp) + assert "int walkIndex =" not in cpp + + +def test_a_loop_returned_by_an_unused_branch_stops_despite_a_sibling_shadow(): + cpp = transpile(source(""" + if bar_index >= 0 + float subtotal = 0.0 + for walkIndex = 0 to walkIndex + subtotal += 1 + float subtotal = 5.0 + """, "plot(subtotal)\n")) + _assert_stop(cpp) + + +def test_a_chain_in_an_unused_branch_stops_despite_a_sibling_shadow(): + cpp = transpile(source(""" + if bar_index >= 0 + float subtotal = 0.0 + for walkIndex = 0 to walkIndex + subtotal += 1 + unusedCopy = subtotal + 1 + float subtotal = 5.0 + firstCopy = subtotal + 1 + secondCopy = firstCopy + 1 + """, "plot(secondCopy)\n")) + _assert_stop(cpp) + + +@pytest.mark.parametrize("name,target", LATER_LOOP_TARGETS.items()) +def test_replacing_a_loop_keeps_the_later_finite_loops_cpp( + name, target, prior_cpp_hashes, +): + cpp = transpile(BASELINE_SOURCES[name]) + # The preceding endless loop changes, so the full C++ must differ. + assert hashlib.sha256(cpp.encode("utf-8")).hexdigest() != ( + prior_cpp_hashes[name]["cpp_sha256"] + ), f"The endless loop was not replaced: {name}" + actual_loop = LATER_FINITE_LOOP.search(cpp) + assert actual_loop is not None, f"Later finite loop missing from C++: {name}" + assert hashlib.sha256(actual_loop.group().encode("utf-8")).hexdigest() == ( + prior_cpp_hashes[name]["later_loop_sha256"] + ), f"Later finite loop differs from the prior counted-loop lowering: {name}" + if target == "acc": + assert "Loop takes too long to execute" in cpp + else: + assert "Loop takes too long to execute" not in cpp + assert not re.search( + rf"\b{target}\s*(?:\+=\s*1|=\s*\({target}\s*\+\s*1\))\s*;", + cpp, + ) + + +def test_multiple_live_loops_share_the_named_message_limit(): + cpp = transpile(source(""" + float acc = 0.0 + for firstIndex = 0 to firstIndex + acc += 1 + for secondIndex = 0 to secondIndex + 1 + acc += 1 + """)) + assert cpp.count("constexpr int kLoopTimeLimitMs = 2000;") == 1 + assert cpp.count('": Loop takes too long to execute (> "') == 2 + + +def test_pure_helper_calls_in_an_unused_reduction_stop(): + cpp = transpile(source(""" + magnitude(float sampleValue) => math.abs(sampleValue) + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += magnitude(walkIndex) + """, "")) + _assert_stop(cpp) + + +def test_reading_a_collection_in_an_unused_loop_stops(): + cpp = transpile(source(""" + values = array.new_float(1, 1.0) + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += array.get(values, 0) + """, "")) + _assert_stop(cpp) + + +def test_an_unreachable_break_does_not_exempt_a_live_endless_loop(): + _assert_stop(transpile(source(""" + float acc = 0.0 + for walkIndex = 0 to walkIndex + acc += 1 + if false + break + """))) From 003abcad0225ea55c005f12445cc2d72285ddd8d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 17:09:45 +0800 Subject: [PATCH 02/14] Refuse a float step against a loop counter with int start evidence, and count inexact float steps as TradingView does (PF-E2079) Two loop behaviours differed from TradingView for a counted for loop with a float step. 1. A float step against an int counter. TradingView refuses a v6 loop whose counter is an int and whose step is a float. We accepted it and narrowed the step to an int (`by 0.5` became `by 0`, which stopped the run at the first bar, or in earlier releases was replaced with 1), so the loop ran with a step the author never wrote. A v6 loop whose start has positive int evidence (an int literal, an int-typed declaration, array.size and the like) and whose step has positive float evidence is now refused at the step expression with PF-E2079 ("The step of a 'for' loop has type float, but counter 'i' has type int"; hint: use an int step or a float counter), in the chart and in the request.security helper, whatever the end's type: TradingView refuses `for i = 0 to 1.0 by 0.25` too. The decision uses the same single loop-counter query that chooses the counter's C++ type (pineforge_codegen/loop_types.py), so analysis and emission cannot disagree. A start whose type is unknown (an untyped function parameter, a start read from a value we cannot type) with a float step is not refused: TradingView runs those loops with a float counter and the float step when the argument is a float, and so do we. v5 library bodies keep their int counters and existing step handling. A step that is zero or negative still stops the run as before. 2. Inexact steps. TradingView counts `0.0 to 0.3 by 0.1` as four iterations, as an accumulation x += step that continues while x is within a small allowance of the end; plain accumulation gives three here. A float-counter loop now accumulates the counter and continues while `x <= end + kFloatStepTolerance` (the mirrored test for a descending step). kFloatStepTolerance = 1e-12 is a named constant in the units of the counter, emitted only when a float-counter loop exists. It is an absolute allowance: measurements at a very large step (1e6) and a very small step (1e-6) rule out an allowance relative to the step or to the end. Measured on TradingView (iteration count of each probe; the export digests, source digests and counts are in tests/fixtures/for_counter_headers/tradingview_quantities.json): 0.0 to 1.0 by 0.1 counts eleven; 0.0 to 0.3 by 0.1 and 0.0 to 0.6 by 0.2 count four; a tenth step to seven tenths counts eight; a descending tenth step counts four; ends short of 0.3 by 1e-3, 1e-5 and 1e-10 count three, so those shortfalls are not absorbed; with a step of 1e6 an end short of the fourth counter value by 1 or by 1e-7 counts three; with a step of 1e3 an end short by 1e-10 counts three; with a step of 1e-6 an end short by 1e-18 counts four. An absolute allowance fits all 18 rows for any value from about 5.6e-17 to about 9.98e-11; 1e-12 is a choice inside that bracket and is not a TradingView constant. Known gaps: TradingView decides an unknown (parameter) start by the call argument's type: the same helper called with an int argument and a float step is refused by TradingView and runs here (a call-site type is not visible to us, and refusing it would also refuse the valid float-argument call); a float counter passed to a user helper without a declared parameter type is truncated to an int by the shared type inference (TradingView passes the fractional value: accumulating twice(x) for x from 0.0 to 1.0 by 0.25 gives 5 there and 2 here), and array.from(x) with a float counter builds an int array; the last two are recorded in the tests as current values against the measured ones. Tests: tests/test_for_float_step.py (the refusal and its position, the allowance constant emitted once for a double counter and never for an int counter, a text check that the allowance reads neither the step nor the end, in the chart and request.security routes); the loop-counter tests that pinned the run stop for an int start with a fractional step now pin the refusal (code, severity, message, hint, step location); the 17 float-step rows with their measured counts, run through the generated C++ in tests/test_e2e_for_counter_headers.py, including the rows that fail for an allowance of 1e-10 and of 1e-17. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 45 ++- CHANGELOG.md | 41 +- CLAUDE.md | 45 ++- docs/PUBLIC_CONTRACT.md | 5 + pineforge_codegen/analyzer/base.py | 4 + pineforge_codegen/codegen/base.py | 6 + pineforge_codegen/codegen/for_loops.py | 14 + pineforge_codegen/codegen/security.py | 5 +- pineforge_codegen/codegen/visit_stmt.py | 22 +- pineforge_codegen/diagnostics_catalog.json | 1 + pineforge_codegen/loop_types.py | 32 +- pineforge_codegen/parser.py | 7 + tests/_for_float_step.py | 42 +++ tests/fixtures/diagnostic_codes_pin.json | 1 + .../float_counter_approximate_third_step.pine | 10 + .../float_counter_descending_tenth_step.pine | 10 + .../float_counter_end_between_steps.pine | 10 + ...unter_end_short_by_hundred_thousandth.pine | 10 + ...at_counter_end_short_by_ten_billionth.pine | 10 + ...float_counter_end_short_by_thousandth.pine | 10 + ...at_counter_fifth_step_to_three_fifths.pine | 10 + .../float_counter_helper_value.pine | 11 + .../float_counter_large_magnitude.pine | 10 + .../float_counter_large_step.pine | 9 + .../float_counter_large_step_short_end.pine | 9 + .../float_counter_mid_step_short_end.pine | 9 + .../float_counter_nonzero_tenth_start.pine | 10 + .../float_counter_small_step.pine | 9 + ...at_counter_tenth_step_to_seven_tenths.pine | 10 + ...at_counter_tenth_step_to_three_tenths.pine | 10 + .../float_counter_three_tenths_step.pine | 10 + .../tradingview_quantities.json | 121 ++++-- tests/test_diagnostic_notes.py | 3 +- tests/test_e2e_for_counter_headers.py | 108 +++++- tests/test_for_counter_headers.py | 60 +++ tests/test_for_counter_type_sources.py | 58 ++- tests/test_for_float_step.py | 353 ++++++++++++++++++ 37 files changed, 1014 insertions(+), 126 deletions(-) create mode 100644 pineforge_codegen/codegen/for_loops.py create mode 100644 tests/_for_float_step.py create mode 100644 tests/fixtures/for_counter_headers/float_counter_approximate_third_step.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_descending_tenth_step.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_end_between_steps.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_end_short_by_hundred_thousandth.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_end_short_by_ten_billionth.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_end_short_by_thousandth.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_fifth_step_to_three_fifths.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_helper_value.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_large_magnitude.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_large_step.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_large_step_short_end.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_mid_step_short_end.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_nonzero_tenth_start.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_small_step.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_tenth_step_to_seven_tenths.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_tenth_step_to_three_tenths.pine create mode 100644 tests/fixtures/for_counter_headers/float_counter_three_tenths_step.pine create mode 100644 tests/test_for_float_step.py diff --git a/AGENTS.md b/AGENTS.md index 40edf970..ab2aae22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -393,10 +393,10 @@ the counter's type stops the run with "'step' in loop must be greater than zero". The float-counter route is v6 only: positive float evidence in the start, or an unknown start with positive float evidence in the step, selects a float counter. A start with positive int evidence keeps an int counter and its existing -end/step narrowing. With a float step that narrows to zero or below, that route -stops with RE10021 at bar 0, a known gap against TradingView's refusal of a float -step with an int counter (`by 0.5` narrows to 0, where earlier releases replaced -it with 1). Without float evidence, counters keep their existing int route. +end/step narrowing. A v6 loop whose start has positive int evidence refuses a +float-typed step at the step expression (`PF-E2079`), with the hint "Use an int +step or a float counter." Without float evidence, counters keep their existing +int route. v5 library bodies keep their int counters and existing step handling. The counter now wins over an outer same-named variable. An affine chart loop proved endless (its end is affine in its own counter) is stopped with the loop-time error when @@ -407,7 +407,17 @@ when their result is read in a shape not measured, where TradingView may drop them. Endless loops in functions, methods and `request.security` still never end here while TradingView stops them; data-dependent endless loops still hang. -The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are +A float-counter loop accumulates its counter and continues while +`x <= end + kFloatStepTolerance` (the mirrored test when descending), within +an absolute allowance of the end in the units of the counter. +`kFloatStepTolerance = 1e-12` is emitted only when a float-counter loop exists. +It is chosen inside the measured bracket about `[5.6e-17, 9.98e-11]`; +it is not a TradingView constant. Measurements at very large and very small +steps rule out an allowance relative to the step or to the end. +A positive infinite step leaves the counter beyond the finite bound after +one iteration; the absolute allowance needs no non-finite-step guard. + +The measured entry-quantity probes in `fixtures/for_counter_headers` include `end_reads_counter` = 1, `end_reads_counter_other` = 3, `step_reads_counter` = 15, `end_reads_outer_value` = 2, `step_reads_outer_int` = 7, `a1_shadow_end_outer_int` = 3, @@ -421,19 +431,24 @@ The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are `fstep_b_body_local_start` = 1.07 and `fstep_c_array_bounds` = 1.03. The measured `first_end_read_start_two_zero_entries` starts its accumulator at 0 and has zero entries. Non-positive-step probes also measure no-trade stops. -Known gaps: a start with positive int evidence and a float step is refused by -TradingView; here a narrowed non-positive step stops with RE10021 at bar 0; -a float -counter passed to an untyped helper truncates here while TradingView passes -the fractional value (`twice(x)` accumulates 2 here versus 5 on TradingView). +The additional inexact-step probes and export hashes are recorded in +`tradingview_quantities.json`: `0.0 to 0.3 by 0.1` counts four iterations, +while `0.0 to 2999.9999999999 by 1000.0` counts three. These rows reject +absolute allowances of `1e-17` and `1e-10`, respectively. +Known gaps: a float counter passed to an untyped helper truncates here while +TradingView passes the fractional value (`twice(x)` accumulates 2 here versus 5 +on TradingView). `array.from(x)` can also truncate a float counter through inferred `array` storage. An untyped parameter can leave the start unknown; a positively float step then selects a float counter without changing shared parameter inference. -The inexact-step last iteration differs: TradingView counts -`0.0 to 0.3 by 0.1` as four iterations, PineForge as three, while -`0.0 to 1.0 by 0.1` counts eleven on both. Block-local/enclosing-counter -bindings, end bounds that change direction during iteration and stops inside -`request.security` have no TradingView tape. Integer-counter controls are guarded +For `f(x)` containing `for v = x to 1.0 by 0.25`, TradingView refuses `f(0)` +with CE10106 and accepts `f(0.0)`. PineForge accepts and runs both with a float +counter because this inference cannot distinguish their call-argument types; +refusing the unknown start would also refuse the valid float-argument call. +Shortfalls inside the measured tolerance bracket are not pinned by a measurement. +Block-local/enclosing-counter bindings, end bounds that change direction during +iteration and stops inside `request.security` have no TradingView tape. +Integer-counter controls are guarded against previous generated C++ apart from the added step-stop statement. Per-bar `request.security` timeframes can have a closed set by value: diff --git a/CHANGELOG.md b/CHANGELOG.md index c09be12c..da3a6f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ supported as exact pairs; on the 0.x line they are independent. See the - Pine v6 calls refuse a positional argument after a named argument with `PF-E2074` at that positional expression. The check also covers strategy declarations, unused functions and v6 library bodies before lowering. +- A v6 counted loop whose start has positive int evidence now refuses a + float-typed step during translation with `PF-E2079` at the step expression + and the hint "Use an int step or a float counter." An unknown start with + a positively float step still selects a float counter. - Counted loops can read their own counter in the end and step expressions, including when it shadows an outer binding and inside a `request.security` helper. The counter now wins over an outer same-named variable. The step is @@ -40,14 +44,9 @@ supported as exact pairs; on the 0.x line they are independent. See the is fixed from the start and the first end read at counter 0, then an end that reads this counter is read again at the start before the first continuation test. The v6 end also refreshes between iterations. A non-positive - v6 step stops the run instead of being converted into a positive step. A float - step against an int counter, refused by TradingView, now stops the run when - it narrows to zero or below; earlier releases replaced a zero narrowed step - with 1. This changes generated C++ for explicit-step loops; regenerate and - relink affected strategies. With a start positively typed int, TradingView - refuses a counter-dependent float step or a float step read from - a variable; PineForge still accepts the variable form and now builds the - counter-dependent form, which previously failed to build. + v6 step stops the run instead of being converted into a positive step. + This changes generated C++ for explicit-step loops; regenerate and + relink affected strategies. - A v6 counted loop uses a float counter when its start has positive float evidence, or when its start is unknown and its step has positive float evidence, including inside a `request.security` helper. A start with positive @@ -55,18 +54,30 @@ supported as exact pairs; on the 0.x line they are independent. See the route is unchanged; v5 library loops keep their existing int-counter route. Counter-family inference follows declaration sources in a dedicated query, independently of shared expression and storage inference. -- Known loop gaps: a start with positive int evidence and a - float step is refused by TradingView, while a step narrowed to zero or below - stops here with RE10021 at bar 0. A float counter passed to +- Float-counter loops accumulate the counter and continue within an absolute + allowance of the end: `x <= end + kFloatStepTolerance` ascending, with the + mirrored comparison descending. `0.0 to 0.3 by 0.1` counts four iterations. + `kFloatStepTolerance = 1e-12` is emitted only for float-counter loops and + chosen inside the measured bracket about `[5.6e-17, 9.98e-11]` in the units + of the counter; it is not a TradingView constant. Measurements at very large + and very small steps rule out an allowance relative to the step or to the end. + `0.0 to 2999.9999999999 by 1000.0` counts three iterations; together with + the `0.3 by 0.1` row it excludes absolute allowances of `1e-10` and `1e-17`, + respectively. A positive infinite step exits after one iteration with a + finite end and needs no non-finite-step guard. +- Known loop gaps: a float counter passed to an untyped helper truncates here, while TradingView passes the fractional value (accumulating `twice(x)` from 0.0 to 1.0 by 0.25 gives 2 here and 5 on TradingView). `array.from(x)` can also truncate a float counter through inferred `array` storage. An unknown start from an untyped parameter with a positively float step now selects a float counter, independently - of shared parameter inference. Inexact steps can differ at - the last iteration: - `0.0 to 0.3 by 0.1` counts three here and four on TradingView, whereas - `0.0 to 1.0 by 0.1` counts eleven on both. Block-local/enclosing-counter + of shared parameter inference. For `f(x)` containing + `for v = x to 1.0 by 0.25`, TradingView refuses `f(0)` with CE10106 and + accepts `f(0.0)`. PineForge accepts and runs both with a float counter + because this inference cannot distinguish their call-argument types; + refusing the unknown start would also refuse the valid float-argument call. + Shortfalls inside the measured tolerance + bracket have no pinned TradingView count. Block-local/enclosing-counter bindings and stops inside `request.security` have no TradingView tape. - A Pine v6 function or method reading a later local without an earlier visible binding is refused during analysis at the read (`PF-E2075`), diff --git a/CLAUDE.md b/CLAUDE.md index f5c7890a..6e3bd5fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -393,10 +393,10 @@ the counter's type stops the run with "'step' in loop must be greater than zero". The float-counter route is v6 only: positive float evidence in the start, or an unknown start with positive float evidence in the step, selects a float counter. A start with positive int evidence keeps an int counter and its existing -end/step narrowing. With a float step that narrows to zero or below, that route -stops with RE10021 at bar 0, a known gap against TradingView's refusal of a float -step with an int counter (`by 0.5` narrows to 0, where earlier releases replaced -it with 1). Without float evidence, counters keep their existing int route. +end/step narrowing. A v6 loop whose start has positive int evidence refuses a +float-typed step at the step expression (`PF-E2079`), with the hint "Use an int +step or a float counter." Without float evidence, counters keep their existing +int route. v5 library bodies keep their int counters and existing step handling. The counter now wins over an outer same-named variable. An affine chart loop proved endless (its end is affine in its own counter) is stopped with the loop-time error when @@ -407,7 +407,17 @@ when their result is read in a shape not measured, where TradingView may drop them. Endless loops in functions, methods and `request.security` still never end here while TradingView stops them; data-dependent endless loops still hang. -The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are +A float-counter loop accumulates its counter and continues while +`x <= end + kFloatStepTolerance` (the mirrored test when descending), within +an absolute allowance of the end in the units of the counter. +`kFloatStepTolerance = 1e-12` is emitted only when a float-counter loop exists. +It is chosen inside the measured bracket about `[5.6e-17, 9.98e-11]`; +it is not a TradingView constant. Measurements at very large and very small +steps rule out an allowance relative to the step or to the end. +A positive infinite step leaves the counter beyond the finite bound after +one iteration; the absolute allowance needs no non-finite-step guard. + +The measured entry-quantity probes in `fixtures/for_counter_headers` include `end_reads_counter` = 1, `end_reads_counter_other` = 3, `step_reads_counter` = 15, `end_reads_outer_value` = 2, `step_reads_outer_int` = 7, `a1_shadow_end_outer_int` = 3, @@ -421,19 +431,24 @@ The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are `fstep_b_body_local_start` = 1.07 and `fstep_c_array_bounds` = 1.03. The measured `first_end_read_start_two_zero_entries` starts its accumulator at 0 and has zero entries. Non-positive-step probes also measure no-trade stops. -Known gaps: a start with positive int evidence and a float step is refused by -TradingView; here a narrowed non-positive step stops with RE10021 at bar 0; -a float -counter passed to an untyped helper truncates here while TradingView passes -the fractional value (`twice(x)` accumulates 2 here versus 5 on TradingView). +The additional inexact-step probes and export hashes are recorded in +`tradingview_quantities.json`: `0.0 to 0.3 by 0.1` counts four iterations, +while `0.0 to 2999.9999999999 by 1000.0` counts three. These rows reject +absolute allowances of `1e-17` and `1e-10`, respectively. +Known gaps: a float counter passed to an untyped helper truncates here while +TradingView passes the fractional value (`twice(x)` accumulates 2 here versus 5 +on TradingView). `array.from(x)` can also truncate a float counter through inferred `array` storage. An untyped parameter can leave the start unknown; a positively float step then selects a float counter without changing shared parameter inference. -The inexact-step last iteration differs: TradingView counts -`0.0 to 0.3 by 0.1` as four iterations, PineForge as three, while -`0.0 to 1.0 by 0.1` counts eleven on both. Block-local/enclosing-counter -bindings, end bounds that change direction during iteration and stops inside -`request.security` have no TradingView tape. Integer-counter controls are guarded +For `f(x)` containing `for v = x to 1.0 by 0.25`, TradingView refuses `f(0)` +with CE10106 and accepts `f(0.0)`. PineForge accepts and runs both with a float +counter because this inference cannot distinguish their call-argument types; +refusing the unknown start would also refuse the valid float-argument call. +Shortfalls inside the measured tolerance bracket are not pinned by a measurement. +Block-local/enclosing-counter bindings, end bounds that change direction during +iteration and stops inside `request.security` have no TradingView tape. +Integer-counter controls are guarded against previous generated C++ apart from the added step-stop statement. Per-bar `request.security` timeframes can have a closed set by value: diff --git a/docs/PUBLIC_CONTRACT.md b/docs/PUBLIC_CONTRACT.md index 349d3882..685b921f 100644 --- a/docs/PUBLIC_CONTRACT.md +++ b/docs/PUBLIC_CONTRACT.md @@ -651,6 +651,11 @@ reading a later local without an earlier visible binding reports `PF-E2075` at that identifier; a later direct global retains `PF-E2053`. Earlier outer bindings remain visible before a local shadow is declared. +A Pine v6 `for` loop whose counter start has positive int evidence reports +`PF-E2079` for a float-typed step. This fatal analyzer diagnostic points at the +step expression, with the hint "Use an int step or a float counter." +Unknown starts are not refused by this diagnostic. + Every `Diagnostic` (in `transpile_full(...)["diagnostics"]`, in a `CompileError`'s `diagnostics`, and in the glue envelopes) carries the stable `code` and `args` introduced in 1.2.0. Since 1.5.0 it also carries diff --git a/pineforge_codegen/analyzer/base.py b/pineforge_codegen/analyzer/base.py index eabeba1d..007b8832 100644 --- a/pineforge_codegen/analyzer/base.py +++ b/pineforge_codegen/analyzer/base.py @@ -6086,6 +6086,10 @@ def _visit_ForStmt(self, node: ForStmt) -> PineType: self._identifier_decl_bindings, self._identifier_binding_scopes, step=node.step, pine_v5=self._pine_version != 6 or self._in_v5_library_definition(node), + counter_name=node.var, + step_location=(node.annotations or {}).get( + "for_step_location", (node.step.loc if node.step else None) or loc, + ), ) for stmt in node.body: self._visit(stmt) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 3c6a14a3..0c17028d 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -307,6 +307,7 @@ class @JOURNAL@ { ) from .constant_fold import fold_numeric_expression from .session_market import SESSION_MARKET_CPP, SESSION_MARKET_MEMBER +from .for_loops import FLOAT_STEP_TOLERANCE_CPP # TypeInferer mixin owns the ~15 type-spec / C++-type inference helpers # previously scattered across this module; see ``codegen/types.py``. @@ -792,6 +793,7 @@ def func_var_storage(owner: str, raw_name: str) -> str: self._security_arg_call_values: dict[tuple, str] = {} self._random_call_counter = 0 self._for_counter = 0 + self._uses_float_step_tolerance = False # Retain each loop node alongside its plan; ids are lookup keys only. self._closed_trade_folds: dict[int, tuple[ForStmt, object]] = {} self._tuple_assign_counter = 0 @@ -5077,6 +5079,7 @@ def _generate_cpp(self) -> str: # 1. Includes self._emit_includes(lines) + float_step_tolerance_at = len(lines) if self._array_journal_members: lines.append(graph_journal.runtime(self, ARRAY_JOURNAL_CPP).replace( "@JOURNAL@", self._array_journal_cpp_name)) @@ -6087,6 +6090,9 @@ def allocate_legacy_flag(base: str) -> str: exports: list[str] = [] emit_capabilities_exports(self, exports) lines[self._capabilities_export_at:self._capabilities_export_at] = exports + # A float counter may first appear in a requested evaluator. + if self._uses_float_step_tolerance: + lines.insert(float_step_tolerance_at, FLOAT_STEP_TOLERANCE_CPP) # Function bodies and security evaluators can select the enum lookup. if self._uses_enum_string_lookup: lines.insert(0, ENUM_STRING_INCLUDE) diff --git a/pineforge_codegen/codegen/for_loops.py b/pineforge_codegen/codegen/for_loops.py new file mode 100644 index 00000000..dc7f2482 --- /dev/null +++ b/pineforge_codegen/codegen/for_loops.py @@ -0,0 +1,14 @@ +"""The absolute continuation allowance for floating counted loops.""" + +# Absolute allowance in the counter's units, chosen inside the measured +# bracket about [5.6e-17, 9.98e-11]; it is not a TradingView constant. +FLOAT_STEP_TOLERANCE_CPP = "static constexpr double kFloatStepTolerance = 1e-12;\n" + + +def for_continuation(counter_cpp_type, var, end, step, down) -> str: + if counter_cpp_type == "double": + return ( + f"({down} ? ({var} >= {end} - ::kFloatStepTolerance)" + f" : ({var} <= {end} + ::kFloatStepTolerance))" + ) + return f"({down} ? ({var} >= {end}) : ({var} <= {end}))" diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 8812cc2b..8dfcb65e 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -5066,10 +5066,13 @@ def render_bound(expr) -> str: lines.append(f"{pad}if ({st_var} == 0) {st_var} = 1;") lines.append(f"{pad}const bool {dn_var} = ({s_var} > {e_var});") refresh = f", {e_var} = {end_cpp}" if end_inline else "" + continuation = self._for_continuation( + counter_cpp_type, var, e_var, st_var, dn_var, + ) lines.append( f"{pad}for ({counter_cpp_type} {var} = {s_var}; " f"!is_na({s_var}) && !is_na({e_var}) && !is_na({st_var}) && " - f"({dn_var} ? ({var} >= {e_var}) : ({var} <= {e_var})); " + f"{continuation}; " f"{var} += ({dn_var} ? -{st_var} : {st_var}){refresh}) {{" ) if stmt.var: diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 092956fa..ae2db963 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -103,6 +103,7 @@ ) from ..symbols import PineType, TypeSpec, method_receiver_type_name from ..external_requests import UNPINNED_ANNOTATION +from .for_loops import for_continuation from .tables import ( ARRAY_NEW_CTORS, DRAWING_TYPE_TO_CPP, @@ -1741,7 +1742,11 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int, if action is not None: # Keep lexical/naming bookkeeping (including nested loop tokens) # identical for statements after this one; discard only its text. - self._visit_counter_bound_for(node, [], indent, value_target) + saved_float_tolerance = self._uses_float_step_tolerance + try: + self._visit_counter_bound_for(node, [], indent, value_target) + finally: + self._uses_float_step_tolerance = saved_float_tolerance if action == "stop": pad = " " * indent lines.append( @@ -1814,16 +1819,24 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int, # Pine v6 evaluates ``to`` before every iteration; v5 (a v5 library's # body) fixes it before the first one. refresh = "" if self._pine_v5_body else f", {e_var} = {end_cpp}" + continuation = self._for_continuation( + counter_cpp_type, var, e_var, step_var, down_var, + ) lines.append( f"{pad}for ({counter_cpp_type} {var} = {s_var}; " f"{na_guard}" - f"({down_var} ? ({var} >= {e_var}) : ({var} <= {e_var})); " + f"{continuation}; " f"{var} += ({down_var} ? -{step_var} : {step_var})" f"{refresh}) {{" ) self._emit_counted_loop_body(node, lines, indent + 1, value_target) lines.append(f"{pad}}}") + def _for_continuation(self, counter_cpp_type, var, end, step, down) -> str: + if counter_cpp_type == "double": + self._uses_float_step_tolerance = True + return for_continuation(counter_cpp_type, var, end, step, down) + def _for_header_reads_new_counter(self, node: ForStmt) -> bool: """The loop's own counter wins in end/step, including over outer names.""" return any( @@ -1939,9 +1952,12 @@ def _emit_counter_bound_loop( # A continue advances the counter and refreshes the end; break does # neither. Initial direction and the once-read step stay fixed. refresh = "" if pine_v5 else f", {e_var} = {end_cpp}" + continuation = self._for_continuation( + counter_cpp_type, var, e_var, step_var, down_var, + ) lines.append( f"{inner}for (; {na_guard}" - f"({down_var} ? ({var} >= {e_var}) : ({var} <= {e_var})); " + f"{continuation}; " f"{var} += ({down_var} ? -{step_var} : {step_var})" f"{refresh}) {{" ) diff --git a/pineforge_codegen/diagnostics_catalog.json b/pineforge_codegen/diagnostics_catalog.json index 3e57a317..42ff1502 100644 --- a/pineforge_codegen/diagnostics_catalog.json +++ b/pineforge_codegen/diagnostics_catalog.json @@ -221,6 +221,7 @@ "PF-E2076": {"severity": "error", "area": "analysis", "message": "Operator ''{op}'' requires a numeric operand; found {operand_type}.", "hint": null, "explanation": "Unary plus and minus require a numeric operand; use an int or float expression.", "args": {"op": {"kind": "keyword"}, "operand_type": {"kind": "type"}}, "user_message": "Operator ''{op}'' requires a numeric operand."}, "PF-E2077": {"severity": "error", "area": "analysis", "message": "The ?: operator requires a bool condition; found {condition_type}.", "hint": null, "explanation": "Conditional selection requires a bool condition; compare the value or convert it to bool first.", "args": {"condition_type": {"kind": "type"}}, "user_message": "The ?: operator requires a bool condition."}, "PF-E2078": {"severity": "error", "area": "analysis", "message": "Operator ''{op}'' cannot combine {operator_operand_description} and {operator_operand_description2}.", "hint": null, "explanation": "Pine v6 requires an operator to receive compatible proven operand types; an operand is named by its type, or by its source text when it is a call result.", "args": {"op": {"kind": "keyword"}, "operator_operand_description": {"kind": "identifier"}, "operator_operand_description2": {"kind": "identifier"}}, "user_message": "Pine v6 cannot apply operator ''{op}'' to {operator_operand_description} and {operator_operand_description2}."}, + "PF-E2079": {"severity": "error", "area": "analysis", "message": "The step of a ''for'' loop has type {step_type}, but counter ''{counter_name}'' has type {counter_type}.", "hint": "Use an int step or a float counter.", "explanation": "Pine v6 refuses a float step for a loop counter whose start has int type; use an int step or a float counter.", "args": {"step_type": {"kind": "type"}, "counter_name": {"kind": "identifier"}, "counter_type": {"kind": "type"}}, "user_message": "The step of a ''for'' loop cannot be a float when counter ''{counter_name}'' is an int."}, "PF-E3001": {"severity": "error", "area": "request-security", "message": "request.security timeframe ''{name}'' cannot be resolved before the first bar: PineForge registers every requested timeframe before the script runs.", "hint": "Pass a literal, an input or a global to the helper that holds request.security.", "explanation": "The timeframe given to request.security is a name whose value PineForge cannot compute before the first bar; use a literal, an input or a global.", "args": {"name": {"kind": "identifier"}}, "user_message": "The timeframe given to request.security is a name whose value PineForge cannot compute before the first bar."}, "PF-E3002": {"severity": "error", "area": "request-security", "message": "request.security timeframe expression cannot be resolved before the first bar: PineForge registers every requested timeframe before the script runs.", "hint": "Pass a literal, an input or a global to the helper that holds request.security.", "explanation": "The timeframe expression given to request.security cannot be computed before the first bar; use a literal, an input or a global.", "args": {}, "user_message": "The timeframe expression given to request.security cannot be computed before the first bar."}, "PF-E3003": {"severity": "error", "area": "request-security", "message": "request.security timeframe switch arms must contain one expression", "hint": "Compute multi-statement timeframe logic before the request.security call or rewrite it as pure switch arms.", "explanation": "A switch that picks a request.security timeframe must have exactly one expression in each arm; compute multi-statement logic beforehand.", "args": {}, "user_message": "A switch that picks a request.security timeframe must have exactly one expression in each arm."}, diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 0d61e024..10e5e3d8 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -8,6 +8,7 @@ TupleAssign, TupleLiteral, UnaryOp, VarDecl, ) from .limits import iter_ast_nodes +from .errors import CompileError, Diagnostic, Level, Phase from .symbols import PineType, TypeSpec from . import signatures as sigs @@ -120,12 +121,25 @@ def declaration(self, node): self.declaration_specs[key] = spec return self.declaration_specs[key] - def counter_spec(self, start, step): + def counter_spec(self, start, step, *, counter_name=None, step_location=None): spec = self.infer(start) - # TradingView refuses a float step with an int counter. For an unknown - # start, a positively float step therefore has only one TradingView-valid - # counter type: float. Positive int starts keep their existing route. - if spec == _FLOAT or (spec in (None, _NA) and self.infer(step) == _FLOAT): + step_spec = self.infer(step) + # Refusal and counter selection use these same source facts. An int + # fallback for an unknown start is not positive int evidence. + if spec == _INT and step_spec == _FLOAT and counter_name is not None: + step_type, counter_type = "float", "int" + raise CompileError([Diagnostic( + level=Level.ERROR, + phase=Phase.ANALYZER, + location=step_location or step.loc, + message=( + f"The step of a 'for' loop has type {step_type}, " + f"but counter '{counter_name}' has type {counter_type}." + ), + hint="Use an int step or a float counter.", + )]) + # Unknown starts with a positively float step keep the float route. + if spec == _FLOAT or (spec in (None, _NA) and step_spec == _FLOAT): return _FLOAT return _INT @@ -273,8 +287,9 @@ def array_result(name, spec): def for_counter_type( start, sources, bindings, scopes, *, step=None, pine_v5=False, counter_types=None, + counter_name=None, step_location=None, ): - """Choose FLOAT from the start, or a float step when the start is unknown.""" + """Choose the counter family; analysis also validates a named counter's step.""" if pine_v5: return PineType.INT if counter_types is not None and id(start) in counter_types: @@ -282,4 +297,7 @@ def for_counter_type( # Reuse that decision rather than a later coarse symbol/storage type. return counter_types[id(start)] query = _CounterQuery(sources, bindings, scopes) - return PineType.FLOAT if query.counter_spec(start, step) == _FLOAT else PineType.INT + spec = query.counter_spec( + start, step, counter_name=counter_name, step_location=step_location, + ) + return PineType.FLOAT if spec == _FLOAT else PineType.INT diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 690271d3..644a5d78 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -1229,13 +1229,20 @@ def _parse_for_stmt(self): self._consume(TokenType.TO) end = self._parse_expression_in_slot("for end") step = None + step_location = None if self._match(TokenType.BY): + step_location = self._loc(self._current()) step = self._parse_expression() + last_token = self.tokens[self.pos - 1] + if last_token.line == step_location.line: + step_location.end_col = last_token.end_col self._consume(TokenType.NEWLINE) self._consume(TokenType.INDENT) body = self._parse_block() self._consume(TokenType.DEDENT) node = ForStmt(var=var_name, start=start, end=end, step=step, body=body) + if step_location is not None: + node.annotations = {"for_step_location": step_location} return self._set_loc(node, start_tok) def _parse_while_stmt(self) -> WhileStmt: diff --git a/tests/_for_float_step.py b/tests/_for_float_step.py new file mode 100644 index 00000000..5090cf60 --- /dev/null +++ b/tests/_for_float_step.py @@ -0,0 +1,42 @@ +"""Diagnostic assertions shared by the loop source inventories.""" + +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError, Level, Phase + + +def assert_float_step_refused( + source: str, *, counter: str = "i", location: tuple[int, int, int] | None = None, +) -> None: + if location is None: + headers = [ + (line_number, match) + for line_number, line in enumerate(source.splitlines(), 1) + if (match := re.match( + rf"^\s*for {re.escape(counter)} = .* by (?P.+)$", line, + )) + ] + assert len(headers) == 1 + line_number, header = headers[0] + location = (line_number, header.start("step") + 1, header.end("step") + 1) + with pytest.raises(CompileError) as caught: + transpile(source, filename="loop.pine") + diagnostic, = caught.value.diagnostics + assert diagnostic.code == "PF-E2079" + assert diagnostic.level is Level.ERROR + assert diagnostic.phase is Phase.ANALYZER + assert diagnostic.message == ( + "The step of a 'for' loop has type float, " + f"but counter '{counter}' has type int." + ) + assert diagnostic.hint == "Use an int step or a float counter." + assert diagnostic.args == { + "step_type": "float", "counter_name": counter, "counter_type": "int", + } + assert diagnostic.location.file == "loop.pine" + assert ( + diagnostic.location.line, diagnostic.location.col, diagnostic.location.end_col, + ) == location diff --git a/tests/fixtures/diagnostic_codes_pin.json b/tests/fixtures/diagnostic_codes_pin.json index 9e891fa8..d3385035 100644 --- a/tests/fixtures/diagnostic_codes_pin.json +++ b/tests/fixtures/diagnostic_codes_pin.json @@ -222,6 +222,7 @@ "PF-E2076": "e263e260a33e786ee5779c096f012196c6bc131337472b4bae7a71d3bd85a4b8", "PF-E2077": "1ec9c254c581065308401318379c3d0062e08ee4a0880c427f364fae0d721cbb", "PF-E2078": "977826f71dead649617e3d5845570f39dda51fbd4016ac6317c261b6ae8f2801", + "PF-E2079": "ffc574710ed665cf0e018d8111ae31dd4b763457f617b61f1cba1b972de59bce", "PF-E3001": "585dad07e797bbe2cfffa9f45e14dee040be5570cc13f8e46fa4c31e953fc92a", "PF-E3002": "db089893825afe5ad1d91934c21107fc58451cadf3d71de2539b9c5805549433", "PF-E3003": "02571beee5163d1596d8a11fe49336dfcf65f86d4fed13254bac6b36023c3c79", diff --git a/tests/fixtures/for_counter_headers/float_counter_approximate_third_step.pine b/tests/fixtures/for_counter_headers/float_counter_approximate_third_step.pine new file mode 100644 index 00000000..b1c0d40c --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_approximate_third_step.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop with an approximate third step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 1.0 by 0.333333333333 + acc += 1 +// TradingView measurement: quantity 4. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_descending_tenth_step.pine b/tests/fixtures/for_counter_headers/float_counter_descending_tenth_step.pine new file mode 100644 index 00000000..5f31e9de --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_descending_tenth_step.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop with a descending tenth step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 1.0 to 0.7 by 0.1 + acc += 1 +// TradingView measurement: quantity 4. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_end_between_steps.pine b/tests/fixtures/for_counter_headers/float_counter_end_between_steps.pine new file mode 100644 index 00000000..76ab8470 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_end_between_steps.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop end between steps", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.29 by 0.1 + acc += 1 +// TradingView measurement: quantity 3; the iteration count is not rounded up. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_end_short_by_hundred_thousandth.pine b/tests/fixtures/for_counter_headers/float_counter_end_short_by_hundred_thousandth.pine new file mode 100644 index 00000000..6ba26f4b --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_end_short_by_hundred_thousandth.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop end short by a hundred thousandth", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.29999 by 0.1 + acc += 1 +// TradingView measurement: quantity 3. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_end_short_by_ten_billionth.pine b/tests/fixtures/for_counter_headers/float_counter_end_short_by_ten_billionth.pine new file mode 100644 index 00000000..33f6880a --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_end_short_by_ten_billionth.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop end short by a ten billionth", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.2999999999 by 0.1 + acc += 1 +// TradingView measurement: quantity 3; a shortfall of 1e-9 of a step is excluded. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_end_short_by_thousandth.pine b/tests/fixtures/for_counter_headers/float_counter_end_short_by_thousandth.pine new file mode 100644 index 00000000..dde96efd --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_end_short_by_thousandth.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop end short by a thousandth", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.299 by 0.1 + acc += 1 +// TradingView measurement: quantity 3. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_fifth_step_to_three_fifths.pine b/tests/fixtures/for_counter_headers/float_counter_fifth_step_to_three_fifths.pine new file mode 100644 index 00000000..57071321 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_fifth_step_to_three_fifths.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Inexact float fifth step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.6 by 0.2 + acc += 1 +// TradingView measurement: quantity 4, including the rounded last step. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_helper_value.pine b/tests/fixtures/for_counter_headers/float_counter_helper_value.pine new file mode 100644 index 00000000..94a97445 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_helper_value.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("Float counter passed to an untyped helper", initial_capital = 1000000, currency = currency.NONE) +twice(v) => v * 2 +float acc = 0.0 +for x = 0.0 to 1.0 by 0.25 + acc += twice(x) +// TradingView measurement: quantity 5 preserves the helper argument's fractions. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_large_magnitude.pine b/tests/fixtures/for_counter_headers/float_counter_large_magnitude.pine new file mode 100644 index 00000000..d2f9a92f --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_large_magnitude.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop with a large start magnitude", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 1000000000.0 to 1000000000.3 by 0.1 + acc += 1 +// TradingView measurement: quantity 3; the tolerance does not scale with the end. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_large_step.pine b/tests/fixtures/for_counter_headers/float_counter_large_step.pine new file mode 100644 index 00000000..9e17f9b9 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_large_step.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Large float step", currency = currency.USDT, initial_capital = 1000000) +pf_count = 0 +for pf_v = 0.0 to 2999999.0 by 1000000.0 + pf_count += 1 +if bar_index % 8 == 0 + strategy.entry("pf_long", strategy.long, qty = 1 + pf_count / 100) +if bar_index % 8 == 1 + strategy.close("pf_long") diff --git a/tests/fixtures/for_counter_headers/float_counter_large_step_short_end.pine b/tests/fixtures/for_counter_headers/float_counter_large_step_short_end.pine new file mode 100644 index 00000000..158b4943 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_large_step_short_end.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Large float step with a short end", currency = currency.USDT, initial_capital = 1000000) +pf_count = 0 +for pf_v = 0.0 to 2999999.9999999 by 1000000.0 + pf_count += 1 +if bar_index % 8 == 0 + strategy.entry("pf_long", strategy.long, qty = 1 + pf_count / 100) +if bar_index % 8 == 1 + strategy.close("pf_long") diff --git a/tests/fixtures/for_counter_headers/float_counter_mid_step_short_end.pine b/tests/fixtures/for_counter_headers/float_counter_mid_step_short_end.pine new file mode 100644 index 00000000..142c7f1c --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_mid_step_short_end.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Mid-sized float step with a short end", currency = currency.USDT, initial_capital = 1000000) +pf_count = 0 +for pf_v = 0.0 to 2999.9999999999 by 1000.0 + pf_count += 1 +if bar_index % 8 == 0 + strategy.entry("pf_long", strategy.long, qty = 1 + pf_count / 100) +if bar_index % 8 == 1 + strategy.close("pf_long") diff --git a/tests/fixtures/for_counter_headers/float_counter_nonzero_tenth_start.pine b/tests/fixtures/for_counter_headers/float_counter_nonzero_tenth_start.pine new file mode 100644 index 00000000..5b4052e1 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_nonzero_tenth_start.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float loop with a nonzero tenth start", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.1 to 0.4 by 0.1 + acc += 1 +// TradingView measurement: quantity 4. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_small_step.pine b/tests/fixtures/for_counter_headers/float_counter_small_step.pine new file mode 100644 index 00000000..876e510b --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_small_step.pine @@ -0,0 +1,9 @@ +//@version=6 +strategy("Small float step", currency = currency.USDT, initial_capital = 1000000) +pf_count = 0 +for pf_v = 0.0 to 0.000003 - 0.000000000000000001 by 0.000001 + pf_count += 1 +if bar_index % 8 == 0 + strategy.entry("pf_long", strategy.long, qty = 1 + pf_count / 100) +if bar_index % 8 == 1 + strategy.close("pf_long") diff --git a/tests/fixtures/for_counter_headers/float_counter_tenth_step_to_seven_tenths.pine b/tests/fixtures/for_counter_headers/float_counter_tenth_step_to_seven_tenths.pine new file mode 100644 index 00000000..8e8e734e --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_tenth_step_to_seven_tenths.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Inexact float step to seven tenths", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.7 by 0.1 + acc += 1 +// TradingView measurement: quantity 8. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_tenth_step_to_three_tenths.pine b/tests/fixtures/for_counter_headers/float_counter_tenth_step_to_three_tenths.pine new file mode 100644 index 00000000..22d552c9 --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_tenth_step_to_three_tenths.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Inexact float step to three tenths", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.3 by 0.1 + acc += 1 +// TradingView measurement: quantity 4, including the rounded last step. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/float_counter_three_tenths_step.pine b/tests/fixtures/for_counter_headers/float_counter_three_tenths_step.pine new file mode 100644 index 00000000..cae360ea --- /dev/null +++ b/tests/fixtures/for_counter_headers/float_counter_three_tenths_step.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("Float three tenths step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 0.9 by 0.3 + acc += 1 +// TradingView measurement: quantity 4. +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index 87df390c..026c077c 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -1,5 +1,11 @@ { - "provenance": "TradingView measurements, 2026-11-02. Each sha256 is the SHA-256 of that probe's TradingView export: the trades list or the reply that stops the run. The export files are not stored in the repository. For float_counter_*, first_end_read_*, int_counter_float_end and step_reads_counter_descending, the sources differ from the exported ones only in the strategy title and comments. The first_end_read_start_two_down and first_end_read_start_two_up probes were measured at 11:57 on BINANCE:BTCUSDT 60; their exports have identical bytes and all 205 entries have quantity 1.1. The zero-entry probe is recorded separately because its accumulator starts at 0. The fstep_* sources are exact copies of the accepted TradingView scripts; their entry quantities encode 5, 7 and 3 loop iterations as 1 + count / 100. Their trade schedule is every eight bars from bar 0, with a close on the next bar.", + "provenance": "TradingView measurements, 2026-11-02. Each sha256 is the SHA-256 of that probe's TradingView export: the trades list or the reply that stops the run. Each source_sha256 identifies the measured source, not necessarily the repository fixture. The export files are not stored in the repository. For float_counter_*, first_end_read_*, int_counter_float_end and step_reads_counter_descending, fixtures preserve the measured expressions but may change strategy titles, comments and whitespace; float_counter_helper_value also places the helper body on its declaration line. The float-counter value and thirteen older iteration-count rows were measured on BINANCE:BTCUSDT 60, including seven tolerance-bracket probes at 12:22; each quantity holds on all 205 entries. The four additional large/small-step rows preserve every source byte except the strategy title; counts 3, 4, 3 and 3 are encoded as entry quantities 1.03, 1.04, 1.03 and 1.03 on all 180 exported entries. Their trade schedule is every eight bars from bar 0, with a close on the next bar. The first_end_read_start_two_down and first_end_read_start_two_up probes were measured at 11:57 on BINANCE:BTCUSDT 60; their exports have identical bytes and all 205 entries have quantity 1.1. The zero-entry probe is recorded separately because its accumulator starts at 0. The fstep_* sources are exact copies of the accepted TradingView scripts; their entry quantities encode 5, 7 and 3 loop iterations as 1 + count / 100. Their trade schedule is every eight bars from bar 0, with a close on the next bar. Compile-refusal spans use TradingView's 1-based inclusive end columns.", + "float_step_tolerance": { + "measured_absolute_bracket": "about [5.6e-17, 9.98e-11] in the units of the counter, from double arithmetic over all 18 measured rows", + "choice": "kFloatStepTolerance = 1e-12 is chosen inside the measured bracket; it is not a TradingView constant", + "rule": "Accumulate the counter; continue within an absolute allowance of the end: x <= end + kFloatStepTolerance ascending, x >= end - kFloatStepTolerance descending", + "measurement": "Measurements at very large and very small steps rule out an allowance relative to the step or to the end. An absolute allowance of 1e-10 fails float_counter_mid_step_short_end; 1e-17 fails float_counter_tenth_step_to_three_tenths and float_counter_fifth_step_to_three_fifths. The large-magnitude row does not constrain the allowance in this bracket." + }, "entries_per_probe": 205, "entry_quantities": { "end_reads_counter": 1, @@ -21,11 +27,32 @@ "first_end_read_start_two_up": 1.1, "float_counter_value": 2.5, "float_counter_tenth_step_to_one": 11, + "float_counter_helper_value": 5, + "float_counter_tenth_step_to_three_tenths": 4, + "float_counter_tenth_step_to_seven_tenths": 8, + "float_counter_fifth_step_to_three_fifths": 4, + "float_counter_end_between_steps": 3, + "float_counter_three_tenths_step": 4, + "float_counter_end_short_by_ten_billionth": 3, + "float_counter_end_short_by_hundred_thousandth": 3, + "float_counter_end_short_by_thousandth": 3, + "float_counter_nonzero_tenth_start": 4, + "float_counter_descending_tenth_step": 4, + "float_counter_approximate_third_step": 4, + "float_counter_large_magnitude": 3, + "float_counter_large_step": 1.03, + "float_counter_small_step": 1.04, + "float_counter_large_step_short_end": 1.03, + "float_counter_mid_step_short_end": 1.03, "fstep_a_param_start": 1.05, "fstep_b_body_local_start": 1.07, "fstep_c_array_bounds": 1.03 }, "trade_schedules": { + "float_counter_large_step": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, + "float_counter_small_step": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, + "float_counter_large_step_short_end": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, + "float_counter_mid_step_short_end": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, "fstep_a_param_start": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, "fstep_b_body_local_start": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, "fstep_c_array_bounds": {"period": 8, "first_entry_bar": 0, "close_delay": 1} @@ -33,6 +60,27 @@ "zero_entry_probes": { "first_end_read_start_two_zero_entries": {"acc": 0, "entries": 0} }, + "compile_refusals": { + "int_start_float_end_float_step": { + "source_shape": "for v = 0 to 1.0 by 0.25", + "verdict": "REFUSED", + "code": "CE10106", + "span": "4:24-4:27", + "source_sha256": "0f499fb83d40959a1dfcd0a9eda45ed50e210324b2188c3b82f21df20c00b7a0", + "error_json_sha256": "612077dc4a00209b5b0c91531abbfa225b8fa3242267f743106c707a689d755f" + }, + "helper_int_argument_float_step": { + "source_shape": "f(x) contains for v = x to 1.0 by 0.25; called as f(0)", + "verdict": "REFUSED", + "code": "CE10106", + "span": "5:31-5:34", + "source_sha256": "9c6aaeb9578724c1fddc0296a679395dd00f0271968d059709cc52d38655a1ec", + "error_json_sha256": "074376481bdab118aebc8fe4548b73616cca98d27e93c1432992435c9d9b85c5" + } + }, + "known_gaps": { + "untyped_parameter_start": "For f(x) containing for v = x to 1.0 by 0.25, TradingView refuses f(0) with CE10106 (helper_int_argument_float_step) and accepts f(0.0). PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call." + }, "runtime_errors": { "step_reads_counter_descending": "RE10021: 'step' in loop must be greater than zero", "s1_step_neg_literal": "RE10021: 'step' in loop must be greater than zero", @@ -42,33 +90,50 @@ "runtime_error_bar": 0, "runtime_error_trade_count": 0, "evidence": { - "end_reads_counter": {"sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b"}, - "end_reads_counter_other": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0"}, - "step_reads_counter": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e"}, - "step_reads_counter_descending": {"sha256": "51b4d60bc9fea3633b56256cc866d2deb0f53d2fb556c8170e1c3be78fcc46b2"}, - "end_reads_outer_value": {"sha256": "fb23b23e01e73fefab0c46fe70a6211212cd15bd952cc3ca069d21ccf25852ef"}, - "step_reads_outer_int": {"sha256": "bcd1addb2b5230319801d9d522c08eb43b1c82ff574ee8f0e27369f5c237fb3d"}, - "a1_shadow_end_outer_int": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0"}, - "a2_shadow_step_outer_int": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e"}, - "a3_shadow_step_parameter": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e"}, - "b1_end_counter_later_global": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0"}, - "sec1_end_counter_in_security": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0"}, - "z1_step_nonzero_start": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e"}, - "float_counter_float_step": {"sha256": "b8483e12741c8f1ae8027260f09c5ff34221c7a9fd8223686f31dd24d89e4242"}, - "float_counter_no_step": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0"}, - "float_counter_unit_step": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e"}, - "int_counter_float_end": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e"}, - "first_end_read_start_two_down": {"sha256": "4ca8b5662f5d7b867812ca7f60f10b014f56a313fc144072f83293ec4feeb43b"}, - "first_end_read_start_two_up": {"sha256": "4ca8b5662f5d7b867812ca7f60f10b014f56a313fc144072f83293ec4feeb43b"}, - "first_end_read_start_two_zero_entries": {"sha256": "a44585e8dcb09098c7fccb8f29b30d822dda00e9a1b4e2b698ecb47b2827e693", "check": "TradingView export contains a header and zero entries"}, - "float_counter_value": {"sha256": "00c263aaea6185d8f08ba099cc36f35ee7e8a1e42b2a29a576020f5e1964a0fc"}, - "float_counter_tenth_step_to_one": {"sha256": "43323a1064ee8e4985e7e5e6f89668284604b00aeb08685e6336a90f9fb67c15"}, - "fstep_a_param_start": {"sha256": "911608833e6f02374d11915e81985fa0cca14ab88053317ed413551adb64059c"}, - "fstep_b_body_local_start": {"sha256": "796704b642eb27b4df7e4867140abae7930ee125fd47c04c8750a6bea1a65920"}, - "fstep_c_array_bounds": {"sha256": "c152179bb7615af5f831fd56aae6791e4c12a8e992a9a690549e1ab8b1d42db7"}, - "s1_step_neg_literal": {"sha256": "2cfe027222a5e99a8aea754cf09dd9e67cb38fbf539d0764fef38a7109a9c038"}, - "s2_step_zero_literal": {"sha256": "72f8208f27a9c6e25cfc796a39c282aa0a22b7b6df9d51f5e95a2226147262e5"}, - "s3_step_neg_variable": {"sha256": "288ff78c63007ccaa0297b7f9d762da761811dd003f0c0ff31bc7909b36676e3"}, + "end_reads_counter": {"sha256": "e84496c2adc3436a4dd5d64496b684eea53b2daaa097ec550f5cb21459deb35b", "source_sha256": "6e7c9ac5494f2e85a31efcb856358ef0133cbf149d4b93447cb15ee9ee1ed522"}, + "end_reads_counter_other": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "8149b6a265232fb359219a2124b1ae9a98e9f861df9f5282f5da4d0c1de3ee1f"}, + "step_reads_counter": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "b61b4c7933e515491fdb16425c99ae51b944078eab5175b4087c6c3a7efdb323"}, + "step_reads_counter_descending": {"sha256": "51b4d60bc9fea3633b56256cc866d2deb0f53d2fb556c8170e1c3be78fcc46b2", "source_sha256": "1148c17b77c93c4c48fea7464312b1d1ddc42b9e3860b3fc5e823c8684481dda"}, + "end_reads_outer_value": {"sha256": "fb23b23e01e73fefab0c46fe70a6211212cd15bd952cc3ca069d21ccf25852ef", "source_sha256": "aacae35479a997ebc30d481bec519ee44315b2c20c2f35b60baf552b6b4f09b6"}, + "step_reads_outer_int": {"sha256": "bcd1addb2b5230319801d9d522c08eb43b1c82ff574ee8f0e27369f5c237fb3d", "source_sha256": "33eaa3304bc034367c754f951c86d0d9960ed94e7e62c4c347d92867705a85bd"}, + "a1_shadow_end_outer_int": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "468baf8cf2a7a0536d44d99cd5db0202a7ed970610097ca219e5bcb9acbd3b39"}, + "a2_shadow_step_outer_int": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "14584ee6d4f44bf049971323b4becb2eb794c73cd7cee3dd74e22313fd9cbdb2"}, + "a3_shadow_step_parameter": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "e6078410fa14ebe7650ae244a41879bb2fb78430bba032ad4e2bbcc37e88c1f0"}, + "b1_end_counter_later_global": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "d92d514d5c0143c146c7f22dbe6e5732e95e9fff25e09b822ffeab7e394e8dcb"}, + "sec1_end_counter_in_security": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "a4a731402d9c154ec1c93f5057623a59eb31320464d789475a5b033615eadac1"}, + "z1_step_nonzero_start": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "acfc630b56bbdb49a0d614fb4f945ea08ee72f4311de79f8ca114632b0aea7b5"}, + "float_counter_float_step": {"sha256": "b8483e12741c8f1ae8027260f09c5ff34221c7a9fd8223686f31dd24d89e4242", "source_sha256": "9706208a352563cf1cf9ce2a1a79f84dfc4eb4a93ef11b66baf5c4c2b4b45b24"}, + "float_counter_no_step": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "563d01f8616b652e8d004ed1d0d8eedfbca129162bd9af3e67a8309732555f2c"}, + "float_counter_unit_step": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "38b15580191950a0af87fcbf6e61542b48c5356362c0964d4e41c90941cf4b7c"}, + "int_counter_float_end": {"sha256": "535bcd7cc99c8146a45e46ad9d0aff6da7c740cc58a4f57eecdf1d2c10d3856e", "source_sha256": "ab70e683024dc0628a5460d15bfa9a8246837b9f3c8e86dc048b3cccf465ed53"}, + "first_end_read_start_two_down": {"sha256": "4ca8b5662f5d7b867812ca7f60f10b014f56a313fc144072f83293ec4feeb43b", "source_sha256": "032c9492b6ea3eaae7196bcd780c08d26063f5ea1b47b983960163067e4e2b8f"}, + "first_end_read_start_two_up": {"sha256": "4ca8b5662f5d7b867812ca7f60f10b014f56a313fc144072f83293ec4feeb43b", "source_sha256": "f8e371c07c8b6eb651667069e171744a78a79e7f5e7ebf5db1c68aeb9b0cf247"}, + "first_end_read_start_two_zero_entries": {"sha256": "a44585e8dcb09098c7fccb8f29b30d822dda00e9a1b4e2b698ecb47b2827e693", "source_sha256": "840cac29d859a3b4889e2fbfd733e0f961892b79e696fc758170e8e93b6216aa", "check": "TradingView export contains a header and zero entries"}, + "float_counter_value": {"sha256": "00c263aaea6185d8f08ba099cc36f35ee7e8a1e42b2a29a576020f5e1964a0fc", "source_sha256": "89ee6cc4411bafb808d980fb894ed5e82b0bb4566e84647b751cbe647441f40c"}, + "float_counter_tenth_step_to_one": {"sha256": "43323a1064ee8e4985e7e5e6f89668284604b00aeb08685e6336a90f9fb67c15", "source_sha256": "933e5484479ceb4918b6ca0842313c5f09d8cc351953848245fa447c8ece3554"}, + "float_counter_helper_value": {"sha256": "b8483e12741c8f1ae8027260f09c5ff34221c7a9fd8223686f31dd24d89e4242", "source_sha256": "82f73b451a78bd6259aa76326ac9ca18a6e55fd6245d4aa7e8bfeece911cff98"}, + "float_counter_tenth_step_to_three_tenths": {"sha256": "b689d36a5e157b86b9ebaaf0fbcdd916245e3b9185bd8dab6a638c20f59eecbd", "source_sha256": "7bbf5bba609c430e99a5dce8584be1a84275a1cb769da21c747f01d3139e03cf"}, + "float_counter_tenth_step_to_seven_tenths": {"sha256": "95c18f57132ae8955b6d8fa59056a37a7a4e020540e81a098e5d7daef34b2f4f", "source_sha256": "266aec3e46db6634779caaef9f940bbc785e73600e0ed5d3a19b421f5939693b"}, + "float_counter_fifth_step_to_three_fifths": {"sha256": "b689d36a5e157b86b9ebaaf0fbcdd916245e3b9185bd8dab6a638c20f59eecbd", "source_sha256": "35be0b14f82a14d47d1754f50468ca6e8207be5e6631eec0762cef23523d4f80"}, + "float_counter_end_between_steps": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "48557b371ff45879f2358539c1ad47c24a792ac3f32ecb6cac83eaaceba82b65"}, + "float_counter_three_tenths_step": {"sha256": "b689d36a5e157b86b9ebaaf0fbcdd916245e3b9185bd8dab6a638c20f59eecbd", "source_sha256": "16a14968a4a3dfd04a96537e1a428f741c4a4421587e867191771e4e8364dc53"}, + "float_counter_end_short_by_ten_billionth": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "e35efe57d0db8bc1838a86404e935b622c62631bbc4d3e6c0f25dd898016ba90"}, + "float_counter_end_short_by_hundred_thousandth": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "020d4cc8ac1d9c6eba47357bd30937c12bfb7061981769ebf966625e0dc15b2b"}, + "float_counter_end_short_by_thousandth": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "53b3b330057ff479c24319fa696be8519ef96628b1112c2dc85816c4aee08faf"}, + "float_counter_nonzero_tenth_start": {"sha256": "b689d36a5e157b86b9ebaaf0fbcdd916245e3b9185bd8dab6a638c20f59eecbd", "source_sha256": "39272b320156be5ed3bae6dc78d541d03dda21498d9b5c8d9bbf9d309442a1c6"}, + "float_counter_descending_tenth_step": {"sha256": "b689d36a5e157b86b9ebaaf0fbcdd916245e3b9185bd8dab6a638c20f59eecbd", "source_sha256": "8504e9807d91920390d1129e08af6bc4496ec46b73f2c48f6bd4c3e528a4f6b7"}, + "float_counter_approximate_third_step": {"sha256": "b689d36a5e157b86b9ebaaf0fbcdd916245e3b9185bd8dab6a638c20f59eecbd", "source_sha256": "95b5988e87a3190445aa791dab36de10dcf53177b4fc31622e5b2338a1e6b09c"}, + "float_counter_large_magnitude": {"sha256": "7d393ff77e70190a9bea3317c4ee9fc02152af0499f3bdabd39561d7e6e2f7e0", "source_sha256": "1f8b4b13dd920f0aeac61fd2e12f3744b15e6e370a593b687679cb93cd99dfad"}, + "float_counter_large_step": {"sha256": "c152179bb7615af5f831fd56aae6791e4c12a8e992a9a690549e1ab8b1d42db7", "source_sha256": "ed78ca07314c21659bf27bb95ec3c23b8cd09238fbd704d674b9e7e21d77cfac"}, + "float_counter_small_step": {"sha256": "23424791b40f7bc83d933bc7597281af751f0a5ba467bb9114744020e687d5e2", "source_sha256": "25ea4bf580102f17d4acbb6a24e9b77106976b2e967c20638c0b3c50010f0af7"}, + "float_counter_large_step_short_end": {"sha256": "c152179bb7615af5f831fd56aae6791e4c12a8e992a9a690549e1ab8b1d42db7", "source_sha256": "48f5db00f481707bea3cc5607fc25548ac2c897aef689df0e4ca66e806f1dc6e"}, + "float_counter_mid_step_short_end": {"sha256": "c152179bb7615af5f831fd56aae6791e4c12a8e992a9a690549e1ab8b1d42db7", "source_sha256": "4d2e6f6db06db034e5028b4eb0515b8ebe2e9b8b580c28c5ede5ec4c79cbdd57"}, + "fstep_a_param_start": {"sha256": "911608833e6f02374d11915e81985fa0cca14ab88053317ed413551adb64059c", "source_sha256": "096dcb41a868d376eb82fa5bbb7c94269229b0ecbfab6409869caba56fbbd74e"}, + "fstep_b_body_local_start": {"sha256": "796704b642eb27b4df7e4867140abae7930ee125fd47c04c8750a6bea1a65920", "source_sha256": "3237f997e24c8cb0cd34d5b5653aee6b8a6674a748f8fa05a8932463371fd52c"}, + "fstep_c_array_bounds": {"sha256": "c152179bb7615af5f831fd56aae6791e4c12a8e992a9a690549e1ab8b1d42db7", "source_sha256": "f304b32d237feffb84a75447a6104a61e017823005368a33ac9cab5c326d6a1c"}, + "s1_step_neg_literal": {"sha256": "2cfe027222a5e99a8aea754cf09dd9e67cb38fbf539d0764fef38a7109a9c038", "source_sha256": "1de17a8b9b8778c5c13d2dac528f0e8d01ed04accd42225addb476861c580069"}, + "s2_step_zero_literal": {"sha256": "72f8208f27a9c6e25cfc796a39c282aa0a22b7b6df9d51f5e95a2226147262e5", "source_sha256": "7f5d5e82820b27068929c020ad684ee151544b78918cf967ebf56edf49fe5859"}, + "s3_step_neg_variable": {"sha256": "288ff78c63007ccaa0297b7f9d762da761811dd003f0c0ff31bc7909b36676e3", "source_sha256": "9a4fb83eb9ab0c57f938060c345673d354bc6dae46097d9309af1d58ced09640"}, "open_counter_header_end": {"check": "builds and runs"}, "open_counter_header_step": {"check": "builds and runs"} } diff --git a/tests/test_diagnostic_notes.py b/tests/test_diagnostic_notes.py index 11c0780d..d8c95f6e 100644 --- a/tests/test_diagnostic_notes.py +++ b/tests/test_diagnostic_notes.py @@ -31,7 +31,7 @@ # source of nz and fixnan (PF-E2047), the operator errors (PF-E2048 to # 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-E2078) and the strategy() declaration errors (PF-E5080 to +# (PF-E2066 to PF-E2079) and the strategy() declaration errors (PF-E5080 to # PF-E5082), the missing value lowering error (PF-E5083) and the requested # selection errors (PF-E3102 to PF-E3105 and PF-E3110), including an unmeasured empty # requested-timeframe arm. @@ -47,6 +47,7 @@ "PF-E2074", # Positional arguments after keyword arguments. "PF-E2075", # A name read where its declaration is not visible. "PF-E2076", "PF-E2077", "PF-E2078", + "PF-E2079", # A float step against a loop counter with int start evidence. "PF-E3102", "PF-E3103", "PF-E3104", "PF-E3105", "PF-E3110", "PF-E3111", "PF-E5080", "PF-E5081", "PF-E5082", "PF-E5083", "PF-W1553"} diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index e5b90d2c..fdec2bfc 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -24,6 +24,17 @@ FIXTURES = Path(__file__).parent / "fixtures" / "for_counter_headers" TV = json.loads((FIXTURES / "tradingview_quantities.json").read_text(encoding="utf-8")) MEASURED = tuple(TV["entry_quantities"]) +# Shared parameter inference still truncates this float counter. Run it as +# a documented discrepancy, retaining TradingView's measured 5 in the quantities file. +HELPER_VALUE_GAP = "float_counter_helper_value" +MATCHING_MEASURED = tuple(name for name in MEASURED if name != HELPER_VALUE_GAP) +# These existing measured rows pin the absolute bracket about +# [5.6e-17, 9.98e-11]: 1e-17 loses the fourth 0.3-by-0.1 iteration; +# 1e-10 wrongly admits the fourth 2999.9999999999-by-1000.0 iteration. +ABSOLUTE_TOLERANCE_BOUNDARY_PROBES = ( + "float_counter_tenth_step_to_three_tenths", + "float_counter_mid_step_short_end", +) FEED_BARS = 1443 ZERO_ENTRY_PROBES = tuple(TV["zero_entry_probes"]) FIRST_END_READ_PROBES = ( @@ -32,6 +43,15 @@ *ZERO_ENTRY_PROBES, ) REAL_SCRIPTS = ("open_counter_header_end", "open_counter_header_step") +# Derived compositions of measured chart counts, without new TradingView tapes. +FLOAT_CONTINUATION_PROBES = ( + "float_counter_tenth_step_to_three_tenths", + "float_counter_fifth_step_to_three_fifths", + "float_counter_descending_tenth_step", + "float_counter_end_between_steps", + "float_counter_end_short_by_ten_billionth", + "float_counter_large_magnitude", +) TRADE_TAIL = """\ if bar_index > 6 and bar_index % 7 == 3 strategy.entry("L", strategy.long, qty = acc) @@ -171,6 +191,11 @@ def runs(tmp_path_factory): # A requested-context composition of the measured chart source; # this is derived coverage, not an additional TradingView tape. builds[f"{name}_in_security"] = Build(_in_security(pine)) + if name in FLOAT_CONTINUATION_PROBES: + bound = _with_counter_bound_step(pine) + builds[f"{name}_bound"] = Build(bound) + builds[f"{name}_in_security"] = Build(_in_security(pine)) + builds[f"{name}_bound_in_security"] = Build(_in_security(bound)) for name in REAL_SCRIPTS: builds[name] = Build((FIXTURES / f"{name}.pine").read_text(encoding="utf-8")) for name, (pine, _) in CONTROLS.items(): @@ -178,6 +203,8 @@ def runs(tmp_path_factory): builds[name] = Build(pine, overrides=overrides) for name, pine in DERIVED_FLOAT_SOURCES.items(): builds[name] = Build(pine) + for name, pine in NONFINITE_FLOAT_SOURCES.items(): + builds[name] = Build(pine) return execute_all(engine, feed, base, builds) @@ -207,24 +234,54 @@ def _assert_quantities( def test_tradingview_evidence_has_complete_sha256(): for name in (*MEASURED, *ZERO_ENTRY_PROBES, *TV["runtime_errors"]): - assert re.fullmatch( - r"[0-9a-fA-F]{64}", TV["evidence"][name]["sha256"] or "" - ), name + for field in ("sha256", "source_sha256"): + assert re.fullmatch( + r"[0-9a-fA-F]{64}", TV["evidence"][name][field] or "" + ), (name, field) + for name, refusal in TV["compile_refusals"].items(): + for field in ("error_json_sha256", "source_sha256"): + assert re.fullmatch( + r"[0-9a-fA-F]{64}", refusal[field] or "" + ), (name, field) + + +@pytest.mark.parametrize("name", tuple( + name for name in MATCHING_MEASURED + if name not in ABSOLUTE_TOLERANCE_BOUNDARY_PROBES +)) +def test_tradingview_entry_quantities(runs, name): + _assert_quantities( + ok(runs, name), "default", TV["entry_quantities"][name], probe=name, + ) -@pytest.mark.parametrize("name", MEASURED) -def test_tradingview_entry_quantities(runs, name): +@pytest.mark.parametrize("name", ABSOLUTE_TOLERANCE_BOUNDARY_PROBES) +def test_tradingview_absolute_tolerance_bracket(runs, name): _assert_quantities( ok(runs, name), "default", TV["entry_quantities"][name], probe=name, ) -@pytest.mark.parametrize("name", MEASURED) +@pytest.mark.parametrize("name", MATCHING_MEASURED) def test_counter_spelling_does_not_change_values(runs, name): _assert_quantities(ok(runs, f"{name}_renamed"), "default", TV["entry_quantities"][name], probe=name) +@pytest.mark.parametrize("suffix", ["", "_renamed"]) +def test_documented_untyped_helper_fraction_gap(runs, suffix): + # This asserts the remaining discrepancy, not TradingView parity. + assert TV["entry_quantities"][HELPER_VALUE_GAP] == 5 + _assert_quantities(ok(runs, f"{HELPER_VALUE_GAP}{suffix}"), "default", 2) + + +@pytest.mark.parametrize("name", FLOAT_CONTINUATION_PROBES) +@pytest.mark.parametrize("suffix", ["_bound", "_in_security", "_bound_in_security"]) +def test_float_continuation_routes_follow_measured_counts(runs, name, suffix): + _assert_quantities(ok(runs, f"{name}{suffix}"), "default", + TV["entry_quantities"][name]) + + @pytest.mark.parametrize("name", FIRST_END_READ_PROBES[:2]) def test_first_end_read_in_security_follows_measured_chart_rule(runs, name): # DERIVED request.security composition of the measured 1.1 chart rows. @@ -307,6 +364,17 @@ def _in_security(pine: str) -> str: ]) +def _with_counter_bound_step(pine: str) -> str: + def bind(match): + counter, step = match["counter"], match["step"] + return match["header"] + f"({counter} >= 0 ? {step} : {step})" + + return re.sub( + r"(?m)^(?P
for (?P\w+) = [^\n]+ by )(?P[^\n]+)$", + bind, pine, + ) + + # DERIVED, no TradingView tape: exact binary step 0.25 gives # (1.0 - 0.0) / 0.25 + 1 = 5 iterations, each adding 1.0. _FLOAT_COUNTER_SOURCE = _source(""" @@ -326,11 +394,39 @@ def _in_security(pine: str) -> str: } +# DERIVED, without a TradingView tape: math.exp lowers to std::exp and +# overflows to +inf. A finite bound admits the initial counter once, then +# the absolute allowance leaves end +/- allowance finite and rejects the +# infinite counter after its first increment. +NONFINITE_FLOAT_SOURCES = {} +for _direction, _start, _end in (("up", "0.0", "1.0"), ("down", "1.0", "0.0")): + _pine = _source(f""" + float acc = 0.0 + for walkIndex = {_start} to {_end} by math.exp(1000.0) + acc += 1.0 + """) + NONFINITE_FLOAT_SOURCES[f"infinite_step_{_direction}"] = _pine + NONFINITE_FLOAT_SOURCES[f"infinite_step_{_direction}_bound"] = ( + _with_counter_bound_step(_pine) + ) + NONFINITE_FLOAT_SOURCES[f"infinite_step_{_direction}_in_security"] = ( + _in_security(_pine) + ) + NONFINITE_FLOAT_SOURCES[f"infinite_step_{_direction}_bound_in_security"] = _in_security( + _with_counter_bound_step(_pine), + ) + + @pytest.mark.parametrize("name", sorted(DERIVED_FLOAT_SOURCES)) def test_derived_float_counter_quantities(runs, name): _assert_quantities(ok(runs, name), "default", 5) +@pytest.mark.parametrize("name", sorted(NONFINITE_FLOAT_SOURCES)) +def test_infinite_float_step_terminates_after_one_iteration(runs, name): + _assert_quantities(ok(runs, name), "default", 1) + + @pytest.mark.parametrize("context", ["chart", "security"]) @pytest.mark.parametrize("name", tuple(TV["runtime_errors"])) def test_nonpositive_step_stops_without_trades(name, context): diff --git a/tests/test_for_counter_headers.py b/tests/test_for_counter_headers.py index 6cdcb78f..19f681f9 100644 --- a/tests/test_for_counter_headers.py +++ b/tests/test_for_counter_headers.py @@ -132,10 +132,70 @@ def test_int_or_unresolved_start_keeps_int_header_slots(name, pine): counter = _counter_pattern(name.split("_", 1)[0], "walkIndex") assert re.search(rf"\bfor \(int {counter} = ", cpp), cpp assert not re.search(rf"\bdouble {counter} = ", cpp), cpp + assert "kFloatStepTolerance" not in cpp for slot in ("start", "end", "step"): assert re.search(rf"\bint \w+(?:_{slot}_\d+|_{slot}) = ", cpp), cpp +@pytest.mark.parametrize("context", ["chart", "security"]) +@pytest.mark.parametrize("counter_bound", [False, True]) +@pytest.mark.parametrize("start,step,cpp_type", [ + ("0.0", "0.1", "double"), + ("1.0", "0.1", "double"), + ("0.0", "math.exp(1000.0)", "double"), + ("1.0", "math.exp(1000.0)", "double"), + ("0", "1", "int"), +]) +def test_only_float_continuations_use_the_absolute_allowance( + context, counter_bound, start, step, cpp_type, +): + if counter_bound: + step = f"(walkIndex >= 0 ? {step} : {step})" + pine = _loop_source( + "float acc = 0.0\n" + f"for walkIndex = {start} to 0.3 by {step}\n" + " acc += 1", + context, + ) + cpp = transpile(pine) + counter = _counter_pattern(context, "walkIndex") + loop = re.search( + rf"\bfor \((?:{cpp_type} {counter} = \w+)?;" + rf"(?P[^;]*\b{counter}\b[^;]*);", + cpp, + ) + assert loop is not None, cpp + condition = loop["condition"] + if cpp_type == "int": + assert "kFloatStepTolerance" not in cpp + assert re.search(r"\? \(\w+ >= \w+\) : \(\w+ <= \w+\)", condition) + return + assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 + literal = re.search(r"kFloatStepTolerance = ([0-9.eE+-]+);", cpp) + assert literal is not None + assert 0 < float(literal[1]) < 1e-9 + assert re.search( + r"\? \(\w+ >= \w+ - ::kFloatStepTolerance\)" + r" : \(\w+ <= \w+ \+ ::kFloatStepTolerance\)", + condition, + ), condition + # Accumulation preserves the measured large-magnitude count. + assert re.search(r"\w+ \+= \(\w+ \? -\w+ : \w+\)", cpp) + + +def test_multiple_float_loops_share_one_tolerance_definition(): + cpp = transpile(_loop_source( + "float acc = 0.0\n" + "for walkIndex = 0.0 to 0.3 by 0.1\n" + " acc += 1\n" + "for otherIndex = 0.0 to 0.6 by 0.2\n" + " acc += 1", + "chart", + )) + assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 + assert cpp.count("::kFloatStepTolerance") == 4 + + # The chart route retains v5's int counter even for a float start. # A v5 library function in a request.security payload is already refused, # so valid source cannot reach that route's v5 counter-type gate. diff --git a/tests/test_for_counter_type_sources.py b/tests/test_for_counter_type_sources.py index 8b0b2df9..938f6ba5 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -17,6 +17,7 @@ from pineforge_codegen.symbols import PineType from tests import _compile as compile_env from tests._for_counter_header_cases import INT_START_SOURCES +from tests._for_float_step import assert_float_step_refused from tests._for_counter_type_source_cases import ( ARRAY_TYPES, BASELINE, BASELINE_SOURCES, FLOAT_COUNTER_SOURCES, INT_COUNTER_SOURCES, MAIN_COMMIT, @@ -135,29 +136,13 @@ def test_float_evidence_runs_without_a_narrowed_step_stop(name, expected): ) == expected -def test_positive_int_start_keeps_the_existing_fractional_step_stop(): - # A declared int parameter is positive int evidence. The narrowed stop - # remains the known gap against TradingView's refusal of this float step. +def test_positive_int_parameter_start_refuses_a_float_step(): + # A declared int parameter supplies positive int evidence to the same + # query that keeps an untyped parameter on the float route. pine = BASELINE_SOURCES["unknown_parameter"].replace( "measure(first) =>", "measure(int first) =>", ).replace("measure(0.0)", "measure(0)") - cpp = transpile(pine) - assert re.search(r"\bfor \(int walkIndex = ", cpp), cpp - driver = r''' -#include -#include -int main() { - GeneratedStrategy strategy; - Bar bar{10.0, 10.0, 10.0, 10.0, 1.0, 60000}; - strategy.run(&bar, 1, "1", "1"); - assert(strategy.trade_count() == 0); - std::cout << strategy.last_error(); -} -''' - error = compile_env.run_emitted_tu( - cpp, driver, opt="-O2", label="int parameter fractional step", - ) - assert "'step' in loop must be greater than zero" in error + assert_float_step_refused(pine, counter="walkIndex", location=(5, 37, 41)) @pytest.mark.parametrize("context", ["chart", "security"]) @@ -169,22 +154,25 @@ def test_positive_int_start_keeps_the_existing_fractional_step_stop(): ("", "", "first", "1", "int"), ("", "stride = ta.median(1, 2)\n", "first", "stride", "int"), ("", "", "0", "1", "int"), - ("", "", "0", "0.25", "int"), - ("", "int begin = 0\n", "begin", "0.25", "int"), - ("", "", "int(first)", "0.25", "int"), - ("begin = input.int(0)\n", "", "begin", "0.25", "int"), - ("", "", "bar_index", "0.25", "int"), - ("", "", "math.floor(first)", "0.25", "int"), - ("", "", "math.round(first)", "0.25", "int"), - ("", "", "(true ? 0 : na)", "0.25", "int"), - ("a = array.from(0.0, 1.0)\n", "", "array.size(a)", "0.25", "int"), - ("a = array.from(0.0, 1.0)\n", "", "a.size()", "0.25", "int"), + ("", "", "0", "0.25", "refused"), + ("", "int begin = 0\n", "begin", "0.25", "refused"), + ("", "", "int(first)", "0.25", "refused"), + ("begin = input.int(0)\n", "", "begin", "0.25", "refused"), + ("", "", "bar_index", "0.25", "refused"), + ("", "", "math.floor(first)", "0.25", "refused"), + ("", "", "math.round(first)", "0.25", "refused"), + ("", "", "(true ? 0 : na)", "0.25", "refused"), + ("a = array.from(0.0, 1.0)\n", "", "array.size(a)", "0.25", "refused"), + ("a = array.from(0.0, 1.0)\n", "", "a.size()", "0.25", "refused"), ]) def test_step_evidence_only_promotes_an_unknown_start( context, counter_bound, prefix, setup, start, step, family, ): # Derive the family from authored evidence, never a signature's FLOAT # default or the analyzer's guessed storage for an untyped parameter. + # The literal int start with float end and step is measured in + # tradingview_quantities.json: int_start_float_end_float_step. + # Other start expressions and requested/bound compositions are derived. end = "(walkIndex >= 0 ? 1.0 : 1.0)" if counter_bound else "1.0" body = ( setup + "float acc = 0.0\n" @@ -201,6 +189,9 @@ def test_step_evidence_only_promotes_an_unknown_start( + "".join(" " + line + "\n" for line in body.splitlines()) + f"plot({call})\n" ) + if family == "refused": + assert_float_step_refused(pine, counter="walkIndex") + return cpp = transpile(pine) counter = "walkIndex" if context == "chart" else r"\w+_\d+_walkIndex" assert re.search(rf"\b{family} {counter} = ", cpp), cpp @@ -214,12 +205,9 @@ def test_step_evidence_only_promotes_an_unknown_start( @pytest.mark.parametrize("name,pine", sorted(INT_START_SOURCES.items())) -def test_existing_positive_int_start_evidence_wins_over_a_float_step(name, pine): +def test_existing_positive_int_start_evidence_refuses_a_float_step(name, pine): # Reuse every existing integer source, including nullable ternaries, # array sizes, trade counts and int-returning math/TA calls. source = pine.replace(" to 0\n", " to 0 by 0.25\n") assert source != pine - cpp = transpile(source) - counter = "walkIndex" if name.startswith("chart_") else r"\w+_\d+_walkIndex" - assert re.search(rf"\bfor \(int {counter} = ", cpp), cpp - assert not re.search(rf"\bdouble {counter} = ", cpp), cpp + assert_float_step_refused(source, counter="walkIndex") diff --git a/tests/test_for_float_step.py b/tests/test_for_float_step.py new file mode 100644 index 00000000..73aaf33a --- /dev/null +++ b/tests/test_for_float_step.py @@ -0,0 +1,353 @@ +"""TradingView loop-step type boundaries and integer-loop controls.""" + +import re + +import pytest + +from pineforge_codegen import transpile, transpile_full +from pineforge_codegen.errors import CompileError +from tests._for_float_step import assert_float_step_refused + +# TradingView refuses this form. +COUNTER_DEPENDENT_STEP = """//@version=6 +strategy("Counter-dependent float step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to 4 by (i == 0 ? 1.0 : 2.0) + acc := acc + i + 1 +// 15 if TradingView accepts a float step for the int counter; a refusal otherwise +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + +# TradingView refuses this form. +VARIABLE_STEP = """//@version=6 +strategy("Loop step reading a variable the body changes", initial_capital = 1000000, currency = currency.NONE) +float stride = 1.0 +float acc = 0.0 +for i = 0 to 6 by stride + stride := stride + 1.0 + acc := acc + 1.0 +// acc is 7 if the step is read once before the loop (0,1,2,3,4,5,6), 3 if it is read again before every iteration (0,1,3,6) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + +# TradingView refuses a float literal step for an int counter. +FLOAT_LITERAL_STEP = """//@version=6 +strategy("Float literal step for an int counter", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to 4 by 1.0 + acc := acc + i + 1 +// 15 if TradingView accepts a float literal step for the int counter; a refusal otherwise +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + +# TradingView accepts this form with quantity 15. +FLOAT_COUNTER_FLOAT_STEP = """//@version=6 +strategy("Float start and float step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0.0 to 4.0 by 1.0 + acc := acc + i + 1 +// 15 if TradingView accepts a float counter with a float step; a refusal otherwise +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + +# TradingView accepts this form with quantity 15. +INT_COUNTER_FLOAT_END = """//@version=6 +strategy("Int start, float end, int step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for i = 0 to 4.0 by 1 + acc := acc + i + 1 +// 15 if TradingView accepts a float end bound for the int counter with an int step; a refusal otherwise +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + +# TradingView accepts this form with quantity 5. +FRACTIONAL_FLOAT_STEP = """//@version=6 +strategy("Fractional float step", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = 0.0 to 1.0 by 0.25 + acc := acc + 1.0 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + +MEASURED_REFUSALS = ( + pytest.param(COUNTER_DEPENDENT_STEP, 4, 19, 39, id="counter-dependent"), + pytest.param(VARIABLE_STEP, 5, 19, 25, id="variable"), + pytest.param(FLOAT_LITERAL_STEP, 4, 19, 22, id="float-literal"), +) + +MEASURED_ACCEPTANCES = ( + pytest.param(FLOAT_COUNTER_FLOAT_STEP, id="float-counter-float-step"), + pytest.param(INT_COUNTER_FLOAT_END, id="int-counter-float-end"), + pytest.param(FRACTIONAL_FLOAT_STEP, id="fractional-float-step"), +) + + +def _assert_step_diagnostic(source: str, line: int, col: int, end_col: int) -> None: + assert_float_step_refused(source, location=(line, col, end_col)) + + +@pytest.mark.parametrize("source,line,col,end_col", MEASURED_REFUSALS) +def test_float_step_is_refused_at_the_step_expression( + source: str, line: int, col: int, end_col: int, +) -> None: + _assert_step_diagnostic(source, line, col, end_col) + + +@pytest.mark.parametrize("source", MEASURED_ACCEPTANCES) +def test_accepted_step_types_do_not_raise_float_step_diagnostic(source: str) -> None: + assert transpile(source) + + +def _in_helper(source: str, requested: bool) -> str: + lines = source.splitlines() + # Keep the declaration and loop body together in the callable's scope. + body = lines[2:lines.index("if bar_index > 6 and bar_index % 7 == 3")] + result = 'request.security(syminfo.tickerid, "60", walk())' if requested else "walk()" + return ( + "\n".join(lines[:2]) + "\nwalk() =>\n" + + "\n".join(" " + line for line in body) + "\n acc\n" + + f"plot({result})\n" + ) + + +@pytest.mark.parametrize("requested", [False, True], ids=["function", "security"]) +@pytest.mark.parametrize("source,line,col,end_col", MEASURED_REFUSALS) +def test_float_step_is_refused_in_helper_bodies( + source: str, line: int, col: int, end_col: int, requested: bool, +) -> None: + _assert_step_diagnostic(_in_helper(source, requested), line + 1, col + 4, end_col + 4) + + +def _script(body: str, version: int = 6) -> str: + return ( + f"//@version={version}\n" + 'strategy("Loop step types", currency=currency.NONE)\n' + body + ) + + +INTEGER_LOOPS = ( + pytest.param(_script( + "float acc = 0.0\n" + "for i = 0 to 4 by 1\n" + " acc += i + 1\n" + "plot(acc)\n" + ), "1", id="int-literal"), + pytest.param(_script( + "int stride = 1\n" + "float acc = 0.0\n" + "for i = 0 to 6 by stride\n" + " stride += 1\n" + " acc += i + 1\n" + "plot(acc)\n" + ), "stride", id="body-modified-int-variable"), + pytest.param(_script( + "float acc = 0.0\n" + "for i = 0 to 4 by (i == 0 ? 1 : 2)\n" + " acc += i + 1\n" + "plot(acc)\n" + ), "(i == 0 ? 1 : 2)", id="counter-dependent-int"), + pytest.param(_script( + "float acc = 0.0\n" + "for i = 0 to 4\n" + " acc += i + 1\n" + "plot(acc)\n" + ), None, id="implicit-step"), + pytest.param(_script( + "walk(stride) =>\n" + " float acc = 0.0\n" + " for i = 0 to 4 by stride\n" + " acc += i + 1\n" + " acc\n" + "plot(walk(1))\n" + ), "stride", id="untyped-step-parameter"), + pytest.param(_script( + "stride = int(1.0)\n" + "float acc = 0.0\n" + "for i = 0 to 4 by stride\n" + " acc += i + 1\n" + "plot(acc)\n" + ), "stride", id="inferred-int-cast"), + pytest.param(_script( + "stride = math.min(1, 2)\n" + "float acc = 0.0\n" + "for i = 0 to 4 by stride\n" + " acc += i + 1\n" + "plot(acc)\n" + ), "stride", id="inferred-int-overload"), +) + + +def _assert_integer_loop_translates(source: str, step: str | None) -> None: + result = transpile_full(source) + assert result["cpp"] + assert all(d.code != "PF-E2079" for d in result["diagnostics"]) + if step is not None: + grouped_source = source.replace(f" by {step}\n", f" by ({step})\n") + assert grouped_source != source + grouped_result = transpile_full(grouped_source) + assert all(d.code != "PF-E2079" for d in grouped_result["diagnostics"]) + assert result["cpp"] == grouped_result["cpp"] + + +@pytest.mark.parametrize("source,step", INTEGER_LOOPS) +def test_integer_loops_still_translate( + source: str, step: str | None, +) -> None: + _assert_integer_loop_translates(source, step) + + +@pytest.mark.parametrize("step", [ + "int(1.0)", "math.min(1, 2)", "math.max(1, 2)", "math.floor(1.5)", + "math.round(1.5)", "nz(1)", +]) +def test_integer_step_expressions_still_translate(step: str) -> None: + source = _script( + "float acc = 0.0\n" + f"for i = 0 to 4 by {step}\n" + " acc += i + 1\n" + "plot(acc)\n" + ) + _assert_integer_loop_translates(source, step) + + +@pytest.mark.parametrize("prefix,start,double_counter", [ + ("", "0.0", True), + ("", "float(0)", True), + ("", "math.max(0, 0.0)", True), + ("", "math.round(0, 2)", True), + ("", "(true ? na : 0.0)", True), + ("first = math.sqrt(0)\n", "first", True), + ("float first = 0\n", "first", True), + ("", "0", False), + ("", "int(0.0)", False), + ("", "math.max(0, 0)", False), + ("", "math.round(0)", False), + ("", "(true ? na : 0)", False), + ("first = math.min(0, 1)\n", "first", False), +]) +def test_float_step_refusal_uses_the_same_counter_family_as_emission( + prefix, start, double_counter, +): + # The literal int start with float end and step is measured in + # tradingview_quantities.json: int_start_float_end_float_step. + # The other start expressions are derived controls. + source = _script( + prefix + "float acc = 0.0\n" + f"for i = {start} to 1.0 by 0.25\n" + " acc += 1\nplot(acc)\n" + ) + if double_counter: + cpp = transpile(source) + assert re.search(r"\bfor \(double i = ", cpp) + else: + assert_float_step_refused(source) + + +@pytest.mark.parametrize("argument", ["0", "0.0"]) +def test_untyped_parameter_start_keeps_documented_call_argument_gap(argument): + # TradingView refuses the int call and accepts the float call. Shared + # parameter inference leaves this start unknown, so both use double here. + source = _script( + "measure(first) =>\n" + " float acc = 0.0\n" + " for i = first to 1.0 by 0.25\n" + " acc += 1.0\n" + " acc\n" + f"plot(measure({argument}))\n" + ) + cpp = transpile(source) + assert re.search(r"\bfor \(double i = ", cpp) + + +# A version-5 loop header does not see its own counter, so only the variable +# step (which does not read the counter) is a valid version-5 source. +@pytest.mark.parametrize("source", [VARIABLE_STEP]) +def test_float_steps_in_v5_library_bodies_still_translate(source: str) -> None: + helper = _in_helper(source, requested=False).splitlines() + library = "\n".join([ + "//@version=5", + 'library("LoopSteps")', + "export walk() =>", + *helper[3:-1], + ]) + "\n" + caller = _script( + "import examples/LoopSteps/1 as steps\n" + "plot(steps.walk())\n" + ) + result = transpile_full(caller, libraries={"examples/LoopSteps/1": library}) + assert result["cpp"] + assert all(d.code != "PF-E2079" for d in result["diagnostics"]) + + +@pytest.mark.parametrize("source", [COUNTER_DEPENDENT_STEP, VARIABLE_STEP]) +def test_top_level_v5_keeps_its_existing_version_diagnostic(source: str) -> None: + # Only library bodies accept v5; top-level scripts already require v6. + with pytest.raises(CompileError) as caught: + transpile(source.replace("//@version=6", "//@version=5")) + diagnostic, = caught.value.diagnostics + assert diagnostic.code != "PF-E2079" + assert diagnostic.message == "PineForge supports PineScript v6 only (found //@version=5)." + + +@pytest.mark.parametrize("tail", ["", "plot(acc)\n"], ids=["omitted", "stopped"]) +@pytest.mark.parametrize("finite_loop_at", [None, "before", "after"]) +def test_removed_float_loops_do_not_request_a_tolerance(tail, finite_loop_at): + removed = ( + "float acc = 0.0\n" + "for i = 0.0 to i by 0.1\n" + " acc += 1\n" + + tail + ) + finite = ( + "float count = 0.0\n" + "for j = 0.0 to 0.3 by 0.1\n" + " count += 1\n" + "plot(count)\n" + ) + body = ( + finite + removed if finite_loop_at == "before" + else removed + finite if finite_loop_at == "after" + else removed + ) + cpp = transpile(_script(body)) + assert not re.search(r"\bfor \(double i\b", cpp) + expected_definitions = int(finite_loop_at is not None) + assert cpp.count("static constexpr double kFloatStepTolerance =") == expected_definitions + assert ("Loop takes too long" in cpp) == bool(tail) + + +def test_variable_step_does_not_scale_the_emitted_allowance(): + cpp = transpile(_script( + 'stride = input.float(0.1, "Stride")\n' + 'limit = input.float(0.3, "Limit")\n' + "float acc = 0.0\n" + "for x = 0.0 to limit by stride\n" + " acc += 1\n" + "plot(acc)\n" + )) + loop = re.search(r"\bfor \(double x = \w+; (?P[^;]+);", cpp) + assert loop is not None, cpp + # Each allowance is only the constant: neither step nor end scales it. + assert re.search( + r"\(\w+ \? \(x >= \w+ - ::kFloatStepTolerance\)" + r" : \(x <= \w+ \+ ::kFloatStepTolerance\)\)$", + loop["condition"], + ), loop["condition"] From 597eda5267428aea1585adbde827e269a38a24cd Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 12:18:04 +0800 Subject: [PATCH 03/14] Allow type keywords as named argument labels Recognize primitive type keywords before equals in call arguments while preserving declaration grammar, argument binding and diagnostic locations. Compare named calls with positional emission and retain grammar controls. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 6 ++ CLAUDE.md | 6 ++ pineforge_codegen/parser.py | 5 +- tests/test_type_keyword_arguments.py | 117 +++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/test_type_keyword_arguments.py diff --git a/AGENTS.md b/AGENTS.md index ab2aae22..7856ade6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,12 @@ at that expression's first token. `_parse_bounded`, direct `Analyzer` clients and v6 library parsing share this rule, including unused bodies and parameter defaults; imported v5 subtrees keep their existing policy. +In call argument lists, the parser accepts a primitive type keyword +(`int`, `float`, `bool`, `string`) followed by `=` as an argument label. +It records the token text as the label and retains written argument order +and source locations. Declaration names and type annotations keep their +existing parser grammar. + 1. `pragmas.extract_pf_trace_pragmas` — pulls `// @pf-trace name=expr` comments out before the lexer strips comments. 2. `Lexer` → `Parser` → `Program` AST. Before the support check, diff --git a/CLAUDE.md b/CLAUDE.md index 6e3bd5fb..6457e869 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,12 @@ at that expression's first token. `_parse_bounded`, direct `Analyzer` clients and v6 library parsing share this rule, including unused bodies and parameter defaults; imported v5 subtrees keep their existing policy. +In call argument lists, the parser accepts a primitive type keyword +(`int`, `float`, `bool`, `string`) followed by `=` as an argument label. +It records the token text as the label and retains written argument order +and source locations. Declaration names and type annotations keep their +existing parser grammar. + 1. `pragmas.extract_pf_trace_pragmas` — pulls `// @pf-trace name=expr` comments out before the lexer strips comments. 2. `Lexer` → `Parser` → `Program` AST. Before the support check, diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 644a5d78..d6ecf44d 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -1729,8 +1729,9 @@ def _parse_call_args(self) -> tuple[list, dict, ArgOrder]: call_arg_order: list = [] while not self._check(TokenType.RPAREN) and not self._at_end(): - # Detect kwargs: IDENT = value (but not IDENT == value) - if (self._current().type == TokenType.IDENT + # A call label may be an identifier or a type keyword, before =. + if ((self._current().type == TokenType.IDENT + or self._current().type in TYPE_KEYWORDS) and self._peek().type == TokenType.EQUALS and self._peek(2).type != TokenType.EQUALS): key_tok = self._advance() diff --git a/tests/test_type_keyword_arguments.py b/tests/test_type_keyword_arguments.py new file mode 100644 index 00000000..a3d6b3d6 --- /dev/null +++ b/tests/test_type_keyword_arguments.py @@ -0,0 +1,117 @@ +"""Type keywords are call labels without becoming declaration names.""" + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError, Level, Phase + + +HEADER = '//@version=6\nstrategy("pf_probe")\n' +TYPE_NAMES = ("int", "float", "bool", "string") + + +def _transpile(body): + return transpile(HEADER + body + "\n", filename="probe.pine") + + +def _error(body): + with pytest.raises(CompileError) as caught: + _transpile(body) + return next(d for d in caught.value.diagnostics if d.level == Level.ERROR) + + +@pytest.mark.parametrize("value", [ + '"abc"', + '"abcdefg"', + 'bar_index % 2 == 0 ? "abcd" : "abcdefg"', + 'str.tostring(math.sqrt(x = 4.0))', +]) +def test_type_keyword_label_lowers_like_positional(value): + expected = _transpile(f"pf_value = str.length({value})") + assert _transpile(f"pf_value = str.length(string = {value})") == expected + + +@pytest.mark.parametrize("name", TYPE_NAMES) +def test_type_keyword_label_still_requires_a_matching_parameter(name): + ordinary = _error("pf_value = ta.sma(pf_unknown = close, length = 5)") + body = f"pf_value = ta.sma({name} = close, length = 5)" + diagnostic = _error(body) + assert diagnostic.code == ordinary.code + assert diagnostic.phase == ordinary.phase + assert diagnostic.message == ordinary.message.replace("pf_unknown", name) + assert diagnostic.location.line == 3 + assert diagnostic.location.col == body.index("close") + 1 + + +@pytest.mark.parametrize("name", TYPE_NAMES) +def test_duplicate_type_keyword_label_keeps_the_label_location(name): + ordinary = _error("pf_value = math.pow(base = 1.0, base = 2.0)") + body = f"pf_value = math.pow({name} = 1.0, {name} = 2.0)" + diagnostic = _error(body) + assert diagnostic.code == ordinary.code + assert diagnostic.phase == Phase.PARSER + assert diagnostic.message == ordinary.message.replace("base", name) + assert diagnostic.hint == ordinary.hint.replace("base", name) + assert diagnostic.location.line == 3 + assert diagnostic.location.col == body.rindex(name) + 1 + assert diagnostic.location.end_col == body.rindex(name) + 1 + len(name) + + +@pytest.mark.parametrize("named,positional", [ + ("ta.sma(source = close, length = 5)", "ta.sma(close, 5)"), + ('input.int(defval = 3, title = "x")', 'input.int(3, "x")'), + ("math.sqrt(x = 4.0)", "math.sqrt(4.0)"), +]) +def test_identifier_keyword_controls_keep_their_emission(named, positional): + expected = _transpile(f"pf_value = {positional}") + assert _transpile(f"pf_value = {named}") == expected + + +@pytest.mark.parametrize("annotation,value", [ + ("string", '"a"'), ("int", "5"), ("float", "5.0"), ("bool", "true"), +]) +def test_type_annotations_keep_their_meaning(annotation, value): + assert _transpile(f"{annotation} pf_value = {value}") + + +@pytest.mark.parametrize("name", TYPE_NAMES) +def test_type_keyword_is_still_refused_as_a_variable_name(name): + assert _error(f"{name} = 5").phase == Phase.PARSER + + +@pytest.mark.parametrize("name", TYPE_NAMES) +def test_type_keyword_is_still_refused_as_a_parameter_name(name): + assert _error(f"pf_echo({name}) => 1.0").phase == Phase.PARSER + + +@pytest.mark.parametrize("name", TYPE_NAMES) +def test_type_keyword_parameter_name_with_default_is_still_refused(name): + control = _error(f"pf_echo({name}) => 1.0") + body = f"pf_echo({name} = 1) => 1.0" + diagnostic = _error(body) + assert diagnostic.code == control.code + assert diagnostic.phase == control.phase == Phase.PARSER + assert diagnostic.location.line == control.location.line + column = body.index("=") + 1 + assert diagnostic.location.col == column + assert diagnostic.location.end_col == column + len("=") + + +@pytest.mark.parametrize("call,late_argument", [ + ('str.substring(source = "abc", 1)', "1"), + ('str.split(string = "abc", ",")', '\",\"'), + ('str.tonumber(string = "123", 4)', "4"), +]) +def test_positional_after_keyword_keeps_its_diagnostic(call, late_argument): + body = f"pf_value = {call}" + diagnostic = _error(body) + assert diagnostic.code == "PF-E2074" + assert diagnostic.location.line == 3 + assert diagnostic.location.col == body.rindex(late_argument) + 1 + + +def test_type_casts_and_equality_are_not_argument_labels(): + assert _transpile("pf_value = math.max(int(4.0), 2)") + assert _transpile("pf_value = math.max(float(4), 2.0)") + assert _transpile("pf_value = bool(4) == true") + assert _transpile('pf_value = string(4) == "4"') From 9fedbf1532f8153f72816ff0880b8b96b11a7579 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 11:54:36 +0800 Subject: [PATCH 04/14] Bind math.pow keywords by parameter name and refuse unknown keyword names math.pow names its second parameter exponent. The registry called it exp, so a keyword exponent was dropped and the call lowered to a one-argument std::pow that does not compile, while the spelling exp compiled and ran. Callables whose declared parameter names are their complete keyword set now carry keyword_names_verified. For such a record an undeclared keyword is refused at its name through the catalog (PF-E2027), in the support check and in the shared merge helper. Records without the flag keep their existing keyword handling byte for byte. math.pow is the first record to carry it. Tests cover the named, mixed and reversed forms against the positional call, the refusal and its span, a flag-driven generic family built at test time, registry coverage of the lookup, and the absence of evidence fields in the registry. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 7 + CLAUDE.md | 7 + pineforge_codegen/codegen/security.py | 1 + pineforge_codegen/codegen/tables.py | 11 +- pineforge_codegen/codegen/visit_call.py | 31 ++- pineforge_codegen/intrinsic_binding.py | 50 +++++ pineforge_codegen/parser.py | 3 + pineforge_codegen/signatures.py | 34 ++- pineforge_codegen/support_checker.py | 4 + tests/test_intrinsic_keyword_binding.py | 146 +++++++++++++ tests/test_intrinsic_keyword_generic.py | 273 ++++++++++++++++++++++++ tests/test_intrinsic_registry.py | 40 ++++ tests/test_signatures.py | 2 +- 13 files changed, 596 insertions(+), 13 deletions(-) create mode 100644 pineforge_codegen/intrinsic_binding.py create mode 100644 tests/test_intrinsic_keyword_binding.py create mode 100644 tests/test_intrinsic_keyword_generic.py create mode 100644 tests/test_intrinsic_registry.py diff --git a/AGENTS.md b/AGENTS.md index 7856ade6..8c4ffe67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,6 +475,13 @@ that replay cannot carry retain PF-E3111. A stateful expanded evaluator cannot fall back to a chart helper call; conditional helper declarations and unsupported state types keep their gates. Fixed requests retain their helper-local `varip` refusal. +Registry builtins with verified keyword names +(`IntrinsicFunc.keyword_names_verified`) declare their complete keyword set +through their signature parameters. These names are validated before lowering +and again at the shared keyword merge; unknown names report PF-E2027 at the +keyword. Other records keep their existing binding policy. `math.pow` +declares `base, exponent`; both named forms emit the positional expression. +Argument ordering remains the separate PF-E2074 validation. An aggregate `var` initializer that reads a statement-computed value (directly, through a user helper, or through another declaration-site `var`) runs at its diff --git a/CLAUDE.md b/CLAUDE.md index 6457e869..2f05d801 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -475,6 +475,13 @@ that replay cannot carry retain PF-E3111. A stateful expanded evaluator cannot fall back to a chart helper call; conditional helper declarations and unsupported state types keep their gates. Fixed requests retain their helper-local `varip` refusal. +Registry builtins with verified keyword names +(`IntrinsicFunc.keyword_names_verified`) declare their complete keyword set +through their signature parameters. These names are validated before lowering +and again at the shared keyword merge; unknown names report PF-E2027 at the +keyword. Other records keep their existing binding policy. `math.pow` +declares `base, exponent`; both named forms emit the positional expression. +Argument ordering remains the separate PF-E2074 validation. An aggregate `var` initializer that reads a statement-computed value (directly, through a user helper, or through another declaration-site `var`) runs at its diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 8dfcb65e..7acf1303 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -4226,6 +4226,7 @@ def _build_security_math_call( node.kwargs, sigs.get_param_names("math", func_name), visit, + intrinsic=sigs.get_intrinsic("math", func_name), ) if func_name == "round" and len(args) == 2: return _math_round_digits_expr(args) diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 600fd243..54be9c77 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -18,6 +18,8 @@ import re +from ..intrinsic_binding import validate_intrinsic_keywords +from ..signatures import IntrinsicFunc from ..symbols import PineType from .helpers import ( evaluate_args_once, na_preserving_int_cast, pine_index_int_cast, @@ -1400,12 +1402,17 @@ def _math_round_digits_expr(args: list[str]) -> str: # kwarg merge helper (used by visitor / dispatch sites that accept kwargs) # --------------------------------------------------------------------------- -def _merge_kwargs(args: list, kwargs: dict, param_names: list | None, visit_expr) -> list: +def _merge_kwargs(args: list, kwargs: dict, param_names: list | None, visit_expr, + *, intrinsic: IntrinsicFunc | None = None) -> list: """Merge positional + kwargs into a unified positional list of C++ exprs. ``param_names`` is the declared signature order from ``signatures.py``. ``visit_expr`` is a callable applied to each kept - AST node; pass ``lambda x: x`` when the caller wants raw nodes back.""" + AST node; pass ``lambda x: x`` when the caller wants raw nodes back. + Registry callers supply their record so verified keyword names are checked + before any argument is evaluated or omitted. + """ + validate_intrinsic_keywords(intrinsic, kwargs) if not kwargs or not param_names: return [visit_expr(a) for a in args] merged = list(args) diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index f29f5e91..a6f3dc04 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -153,6 +153,7 @@ ) from ..collection_history import history_annotation, literal_offset from ..external_requests import RECORDED_KEY_ANNOTATION, request_stop_marker +from ..intrinsic_binding import validate_intrinsic_keywords from ..limits import iter_ast_nodes from ..security_contexts import ticker_symbol_arg from ..symbols import TypeSpec, method_receiver_type_name @@ -2384,6 +2385,7 @@ def _visit_func_call(self, node: FuncCall) -> str: args = _merge_kwargs( node.args, node.kwargs, sigs.get_param_names("request", func_name), self._visit_expr, + intrinsic=sigs.get_intrinsic("request", func_name), ) effects = [f"(void)({arg})" for arg in args] effects += [request_stop(marker), "na()"] @@ -2623,7 +2625,10 @@ def _visit_func_call(self, node: FuncCall) -> str: return lowered node = self._check_time_bars_back(func_name, node) if func_name == "time" and namespace is None and (node.args or node.kwargs): - args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time"), self._visit_expr) + args = _merge_kwargs( + node.args, node.kwargs, sigs.get_param_names(None, "time"), + self._visit_expr, intrinsic=sigs.get_intrinsic(None, "time"), + ) tf_e = args[0] if len(args) > 0 else 'script_tf_' sess = args[1] if len(args) > 1 else 'std::string("")' tz_e = args[2] if len(args) > 2 else 'std::string("")' @@ -2634,7 +2639,10 @@ def _visit_func_call(self, node: FuncCall) -> str: # time_close(timeframe) or time_close(tf, session, tz) — same # session-tz default as time() above. if func_name == "time_close" and namespace is None and (node.args or node.kwargs): - args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time_close"), self._visit_expr) + args = _merge_kwargs( + node.args, node.kwargs, sigs.get_param_names(None, "time_close"), + self._visit_expr, intrinsic=sigs.get_intrinsic(None, "time_close"), + ) tf_e = args[0] if len(args) > 0 else 'script_tf_' sess = args[1] if len(args) > 1 else 'std::string("")' tz_e = args[2] if len(args) > 2 else 'std::string("")' @@ -2714,6 +2722,7 @@ def _visit_func_call(self, node: FuncCall) -> str: node.args, node.kwargs, sigs.get_param_names(None, "timestamp"), lambda a: a, + intrinsic=sigs.get_intrinsic(None, "timestamp"), ) if len(merged) < 3 or any(a is None for a in merged[:3]): self._codegen_error( @@ -2816,6 +2825,7 @@ def _visit_func_call(self, node: FuncCall) -> str: node.kwargs, sigs.get_param_names("ta", "pivot_point_levels"), self._visit_expr, + intrinsic=sigs.get_intrinsic("ta", "pivot_point_levels"), ) else: args = [self._visit_expr(a) for a in node.args] @@ -3250,7 +3260,8 @@ def _visit_arg_for_series(arg_node, arg_idx): # Known intrinsic — use signature registry for kwargs resolution param_names = sigs.get_param_names(namespace, func_name) merged = _merge_kwargs( - node.args, node.kwargs, param_names, lambda a: a + node.args, node.kwargs, param_names, lambda a: a, + intrinsic=sigs.get_intrinsic(namespace, func_name), ) ordered_arg_nodes = list(merged) all_args = [self._visit_expr(a) for a in merged] @@ -3866,10 +3877,12 @@ def _str_format_expr(self, fmt_node, arg_nodes) -> str: def _visit_str_call(self, func_name: str, node) -> str: args = _merge_kwargs(node.args, node.kwargs, - sigs.get_param_names("str", func_name), self._visit_expr) + sigs.get_param_names("str", func_name), self._visit_expr, + intrinsic=sigs.get_intrinsic("str", func_name)) arg_nodes = _merge_kwargs( node.args, node.kwargs, sigs.get_param_names("str", func_name), lambda a: a, + intrinsic=sigs.get_intrinsic("str", func_name), ) if func_name == "repeat": @@ -3996,7 +4009,10 @@ def checked_substring(bound): return f'std::string("") /* unsupported: str.{comment_name} */' def _visit_math_call(self, func_name: str, node: FuncCall) -> str: - args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names("math", func_name), self._visit_expr) + args = _merge_kwargs( + node.args, node.kwargs, sigs.get_param_names("math", func_name), + self._visit_expr, intrinsic=sigs.get_intrinsic("math", func_name), + ) # Handle special cases first if func_name == "round" and len(args) == 2: return _math_round_digits_expr(args) @@ -4043,6 +4059,11 @@ def _resolve_func_args(self, node: FuncCall, sig_key: str) -> dict: """ # sig_key is like "strategy.entry" -> namespace="strategy", func_name="entry" parts = sig_key.split(".", 1) + validate_intrinsic_keywords( + sigs.get_intrinsic(parts[0], parts[1]) if len(parts) == 2 + else sigs.get_intrinsic(None, sig_key), + node.kwargs, + ) if len(parts) == 2: param_names = sigs.get_param_names(parts[0], parts[1]) or [] else: diff --git a/pineforge_codegen/intrinsic_binding.py b/pineforge_codegen/intrinsic_binding.py new file mode 100644 index 00000000..0ec5443c --- /dev/null +++ b/pineforge_codegen/intrinsic_binding.py @@ -0,0 +1,50 @@ +"""Keyword-name validation for intrinsic records with verified keyword names.""" + +from __future__ import annotations + +from .ast_nodes import ASTNode +from .diagnostic_codes import render_diagnostic +from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation +from .pine_spelling import expr_start +from .signatures import IntrinsicFunc + + +def intrinsic_keyword_diagnostics( + intrinsic: IntrinsicFunc | None, + kwargs: dict[str, ASTNode], +) -> list[Diagnostic]: + """Check the complete keyword set of records with verified keyword names. + + Other records retain their existing binding, including ignored names. + This checks names, leaving argument order and other binding policies to + their existing validators. + """ + if intrinsic is None or not intrinsic.keyword_names_verified or not kwargs: + return [] + names = {param.name for signature in intrinsic.signatures + for param in signature.params} + diagnostics = [] + for name, value in kwargs.items(): + if name in names: + continue + location = ((value.annotations or {}).get("keyword_location") + or expr_start(value).loc or value.loc + or SourceLocation("", 1, 1, 2)) + message, hint = render_diagnostic( + "PF-E2027", {"key": intrinsic.name, "name": name}, + ) + diagnostics.append(Diagnostic( + level=Level.ERROR, phase=Phase.ANALYZER, location=location, + message=message, hint=hint, + )) + return diagnostics + + +def validate_intrinsic_keywords( + intrinsic: IntrinsicFunc | None, + kwargs: dict[str, ASTNode], +) -> None: + """Validate verified keyword names for direct codegen clients.""" + diagnostics = intrinsic_keyword_diagnostics(intrinsic, kwargs) + if diagnostics: + raise CompileError(diagnostics) diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index d6ecf44d..1130c3a9 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -1755,6 +1755,9 @@ def _parse_call_args(self) -> tuple[list, dict, ArgOrder]: ) self._advance() # consume = val = self._parse_expression() + val.annotations = { + **(val.annotations or {}), "keyword_location": self._loc(key_tok), + } kwargs[key_tok.value] = val call_arg_order.append(val) else: diff --git a/pineforge_codegen/signatures.py b/pineforge_codegen/signatures.py index 45fdc7c4..0ec84700 100644 --- a/pineforge_codegen/signatures.py +++ b/pineforge_codegen/signatures.py @@ -40,6 +40,9 @@ class IntrinsicFunc: """Complete intrinsic function definition with all overloads.""" name: str # e.g. "ta.sma" or "math.abs" signatures: list[FuncSig] = field(default_factory=list) + # Declared parameter names are the complete, verified keyword set. + # False preserves the record's existing keyword policy. + keyword_names_verified: bool = False @property def primary(self) -> FuncSig: @@ -73,8 +76,10 @@ def _sig(params: list[tuple], ret: PineType = PineType.FLOAT, returns_tuple=returns_tuple, tuple_count=tuple_count) -def _func(name: str, *sigs: FuncSig) -> IntrinsicFunc: - return IntrinsicFunc(name=name, signatures=list(sigs)) +def _func(name: str, *sigs: FuncSig, + keyword_names_verified: bool = False) -> IntrinsicFunc: + return IntrinsicFunc(name=name, signatures=list(sigs), + keyword_names_verified=keyword_names_verified) # Shortcuts for common param types @@ -234,8 +239,11 @@ def _ta(short_name: str, *sigs: FuncSig) -> None: MATH_FUNCTIONS: dict[str, IntrinsicFunc] = {} -def _math(short_name: str, *sigs: FuncSig) -> None: - MATH_FUNCTIONS[short_name] = _func(f"math.{short_name}", *sigs) +def _math(short_name: str, *sigs: FuncSig, + keyword_names_verified: bool = False) -> None: + MATH_FUNCTIONS[short_name] = _func( + f"math.{short_name}", *sigs, keyword_names_verified=keyword_names_verified, + ) # Arithmetic _math("abs", _sig([("x", F)])) @@ -261,7 +269,7 @@ def _math(short_name: str, *sigs: FuncSig) -> None: _sig([("x", F), ("y", F), ("z", F), ("w", F), ("u", F)]), _sig([("x", F), ("y", F), ("z", F), ("w", F), ("u", F), ("v", F)])) _math("sum", _sig([("source", F), ("length", I)])) # rolling sum → math::Sum in runtime -_math("pow", _sig([("base", F), ("exp", F)])) +_math("pow", _sig([("base", F), ("exponent", F)]), keyword_names_verified=True) _math("sqrt", _sig([("x", F)])) _math("exp", _sig([("x", F)])) _math("log", _sig([("x", F)])) @@ -767,6 +775,22 @@ def merge_kwargs_to_positional( return merged +def get_intrinsic(namespace: str | None, func_name: str) -> IntrinsicFunc | None: + """Look up a registry record and its keyword-name validation policy.""" + registry = { + "ta": TA_FUNCTIONS, + "math": MATH_FUNCTIONS, + "strategy": STRATEGY_FUNCTIONS, + "str": STR_FUNCTIONS, + "input": INPUT_FUNCTIONS, + "map": MAP_FUNCTIONS, + "syminfo": SYMINFO_FUNCTIONS, + "request": REQUEST_FUNCTIONS, + None: BUILTIN_FUNCTIONS, + }.get(namespace) + return registry.get(func_name) if registry is not None else None + + def get_param_names(namespace: str | None, func_name: str) -> list[str] | None: """Get parameter names for a function (for kwargs resolution). diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 867e1595..ca3da8b4 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -50,6 +50,7 @@ ) from .errors import SourceLocation, Diagnostic, CompileError, Level, Phase from .builtin_keywords import POSITIONAL_BUILTINS +from .intrinsic_binding import intrinsic_keyword_diagnostics from .pine_spelling import expr_start from .external_requests import ( ABSENT_LOWERING, CHART_FALLBACK_ANNOTATION, FEED_LOWERING, FEED_WARNING_ANNOTATION, @@ -1635,6 +1636,9 @@ def _visit_FuncCall(self, node: FuncCall) -> None: return full = f"{ns}.{name}" if ns else name + self._diagnostics.extend(intrinsic_keyword_diagnostics( + sigs.get_intrinsic(ns, name), node.kwargs, + )) # The footprint another symbol's feed carries: the column of the # request.security whose whole expression this is. diff --git a/tests/test_intrinsic_keyword_binding.py b/tests/test_intrinsic_keyword_binding.py new file mode 100644 index 00000000..999eec70 --- /dev/null +++ b/tests/test_intrinsic_keyword_binding.py @@ -0,0 +1,146 @@ +"""Named intrinsic arguments retain the same expression as positional calls.""" + +from __future__ import annotations + +import csv +import io +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError +from tests._e2e import ( + Build, chart_feed_head, execute_all, ok, skip_unless_e2e_env, +) + + +HEADER = '//@version=6\nstrategy("pf_probe")\n' +POSITIONAL = "math.pow(3.0, 2.0)" +NAMED = ( + "math.pow(base = 3.0, exponent = 2.0)", + "math.pow(3.0, exponent = 2.0)", + "math.pow(exponent = 2.0, base = 3.0)", +) + + +def _source(expression: str) -> str: + return HEADER + f"pf_value = {expression}\nplot(pf_value)\n" + + +def _pow_expressions(cpp: str) -> list[str]: + """Extract calls with their balanced argument lists, including nested calls.""" + calls = [] + for match in re.finditer(r"\bstd::pow\(", cpp): + depth = 1 + end = match.end() + while depth and end < len(cpp): + depth += (cpp[end] == "(") - (cpp[end] == ")") + end += 1 + assert depth == 0, "unbalanced generated pow call" + calls.append(cpp[match.start():end]) + assert calls, "no generated pow call" + return calls + + +@pytest.mark.parametrize("expression", NAMED) +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +def test_named_arguments_emit_the_positional_expression(expression, context): + expected = transpile(_source(context.format(expression=POSITIONAL))) + actual = transpile(_source(context.format(expression=expression))) + assert _pow_expressions(actual) == _pow_expressions(expected) + + +@pytest.mark.parametrize("body", ( + "pf_value = {expression}\n", + "pf_unused() => {expression}\npf_value = close\n", + "if false\n pf_value = {expression}\n", + "pf_value = switch\n close > open => {expression}\n => 0.0\n", + 'pf_value = request.security(syminfo.tickerid, "60", {expression})\n', +)) +@pytest.mark.parametrize("expression", ( + "math.pow(3.0, exp = 2.0)", + "math.pow(3.0,\n exp = 1.0 + close)", +)) +def test_unknown_keyword_is_refused_at_its_name(body, expression): + source = HEADER + body.format(expression=expression) + with pytest.raises(CompileError) as caught: + transpile(source, filename="keywords.pine") + diagnostics = [d for d in caught.value.diagnostics if d.code == "PF-E2027"] + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.args == {"key": "math.pow", "name": "exp"} + lines = source.splitlines() + line = next(index for index, value in enumerate(lines, 1) if "exp =" in value) + col = lines[line - 1].index("exp =") + 1 + assert diagnostic.location.file == "keywords.pine" + assert (diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col) == (line, col, col + len("exp")) + + +def test_positional_after_keyword_keeps_the_argument_order_diagnostic(): + source = _source("math.pow(exponent = 2.0, 3.0)") + with pytest.raises(CompileError) as caught: + transpile(source) + assert [d.code for d in caught.value.diagnostics] == ["PF-E2074"] + line = source.splitlines()[2] + assert caught.value.diagnostics[0].location.col == line.index("3.0") + 1 + + +@pytest.mark.parametrize("keyword, positional", ( + ("ta.sma(source = close, length = 5)", "ta.sma(close, 5)"), + ('input.int(defval = 3, title = "x")', 'input.int(3, "x")'), + ("math.sqrt(x = 4.0)", "math.sqrt(4.0)"), +)) +def test_existing_named_controls_keep_their_positional_emission(keyword, positional): + assert transpile(_source(keyword)) == transpile(_source(positional)) + + +def test_a_record_without_verified_names_keeps_its_existing_keyword_policy(): + assert transpile(_source("math.sqrt(4.0, pf_unused = 2.0)")) == \ + transpile(_source("math.sqrt(4.0)")) + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +def test_codegen_also_validates_when_the_support_check_is_disabled(context): + expression = context.format(expression="math.pow(3.0, exp = 2.0)") + with pytest.raises(CompileError) as caught: + transpile(_source(expression), check_support=False) + assert [d.code for d in caught.value.diagnostics] == ["PF-E2027"] + + +def test_named_power_values_compile_and_run_like_the_positional_control(tmp_path): + engine = skip_unless_e2e_env() + feed = chart_feed_head(engine, tmp_path, 80) + tail = ( + "\n// @pf-trace pf_value=pf_value\n" + "if bar_index % 8 == 0\n" + ' strategy.entry("pf_long", strategy.long, qty = 1 + pf_value / 100)\n' + "if bar_index % 8 == 1\n" + ' strategy.close("pf_long")\n' + ) + expressions = (POSITIONAL, *NAMED) + runs = execute_all(engine, feed, tmp_path, { + str(index): Build(HEADER + f"pf_value = {expression}\n" + tail, trace=True) + for index, expression in enumerate(expressions) + }) + reference = ok(runs, "0") + assert reference.trades["default"] + assert reference.traces["default"] + for record in reference.traces["default"]: + assert record["name"] == "pf_value" + assert float(record["value"]) == pytest.approx(3.0 ** 2.0) + rows = list(csv.DictReader(io.StringIO(reference.trades["default"].decode()))) + assert rows + assert all(float(row["Qty"]) == pytest.approx(1 + (3.0 ** 2.0) / 100) + for row in rows) + for index in range(1, len(expressions)): + actual = ok(runs, str(index)) + assert actual.trades == reference.trades + assert actual.traces == reference.traces diff --git a/tests/test_intrinsic_keyword_generic.py b/tests/test_intrinsic_keyword_generic.py new file mode 100644 index 00000000..f10a141f --- /dev/null +++ b/tests/test_intrinsic_keyword_generic.py @@ -0,0 +1,273 @@ +"""Verified keyword names follow each callable's registry record.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import replace +import re + +import pytest + +from pineforge_codegen import signatures, transpile +from pineforge_codegen.diagnostic_codes import render_diagnostic +from pineforge_codegen.errors import CompileError, Level +from pineforge_codegen.signatures import IntrinsicFunc + + +def _source(expression): + return ( + '//@version=6\nstrategy("pf_probe")\n' + f"pf_value = {expression}\nplot(pf_value)\n" + ) + + +def _arguments(intrinsic): + return [ + (param.name, f"{index + 2}.0") + for index, param in enumerate(intrinsic.primary.params) + ] + + +def _call(intrinsic, arguments): + return f"{intrinsic.name}({', '.join(arguments)})" + + +def _expressions(cpp, intrinsic): + short_name = intrinsic.name.rsplit(".", 1)[-1] + expressions = [] + for match in re.finditer(rf"\bstd::{re.escape(short_name)}\(", cpp): + depth = 1 + end = match.end() + while depth and end < len(cpp): + depth += (cpp[end] == "(") - (cpp[end] == ")") + end += 1 + assert depth == 0 + expressions.append(cpp[match.start():end]) + assert expressions, intrinsic.name + return expressions + + +@pytest.fixture(params=("sqrt", "atan2")) +def intrinsic_case(request, monkeypatch): + """Synthetic parameters use existing dispatch without a visitor change.""" + original = signatures.MATH_FUNCTIONS[request.param] + assert original.keyword_names_verified is False + existing_names = { + param.name + for registry in vars(signatures).values() + if isinstance(registry, dict) + for record in registry.values() + if isinstance(record, IntrinsicFunc) + for signature in record.signatures + for param in signature.params + } + intrinsic = replace(original, signatures=[ + replace(signature, params=[ + replace(param, name=f"pf_synthetic_argument_{index}") + for index, param in enumerate(signature.params) + ]) + for signature in original.signatures + ]) + assert not existing_names.intersection(intrinsic.param_names) + + @contextmanager + def with_verified_names(): + with monkeypatch.context() as patch: + patch.setattr(intrinsic, "keyword_names_verified", True) + yield + assert intrinsic.keyword_names_verified is False + + with monkeypatch.context() as patch: + patch.setitem(signatures.MATH_FUNCTIONS, request.param, intrinsic) + yield intrinsic, with_verified_names + assert signatures.MATH_FUNCTIONS[request.param] is original + assert original.keyword_names_verified is False + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +def test_verified_names_accept_declared_keywords( + intrinsic_case, context, check_support, +): + intrinsic, with_verified_names = intrinsic_case + arguments = _arguments(intrinsic) + positional = _call(intrinsic, [value for _, value in arguments]) + expected = _expressions(transpile( + _source(context.format(expression=positional)), check_support=check_support, + ), intrinsic) + named_forms = ( + [f"{name} = {value}" for name, value in arguments], + [f"{name} = {value}" for name, value in reversed(arguments)], + [arguments[0][1]] + + [f"{name} = {value}" for name, value in arguments[1:]], + ) + with with_verified_names(): + for named in named_forms: + actual = transpile( + _source(context.format(expression=_call(intrinsic, named))), + check_support=check_support, + ) + assert _expressions(actual, intrinsic) == expected + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +@pytest.mark.parametrize("keyword_layout", ( + "{name} = 2.0", + "\n {name} = 1.0 + close", +)) +def test_verified_names_refuse_an_unknown_keyword_at_its_computed_span( + intrinsic_case, context, check_support, keyword_layout, +): + intrinsic, with_verified_names = intrinsic_case + arguments = _arguments(intrinsic) + unknown = f"pf_unknown_{arguments[0][0]}" + assert unknown not in intrinsic.param_names + expression = _call(intrinsic, [ + *[value for _, value in arguments], + keyword_layout.format(name=unknown), + ]) + source = _source(context.format(expression=expression)) + start = source.index(f"{unknown} =") + line = source.count("\n", 0, start) + 1 + col = start - source.rfind("\n", 0, start) + expected_args = {"key": intrinsic.name, "name": unknown} + expected_text = render_diagnostic("PF-E2027", expected_args) + with with_verified_names(): + with pytest.raises(CompileError) as caught: + transpile( + source, filename="generic_keywords.pine", + check_support=check_support, + ) + diagnostics = [ + diagnostic for diagnostic in caught.value.diagnostics + if diagnostic.level == Level.ERROR + ] + assert len(diagnostics) == 1 + diagnostic = diagnostics[0] + assert diagnostic.code == "PF-E2027" + assert diagnostic.args == expected_args + assert (diagnostic.message, diagnostic.hint) == expected_text + assert diagnostic.location.file == "generic_keywords.pine" + assert ( + diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col, + ) == (line, col, col + len(unknown)) + + +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +def test_clearing_verified_names_restores_identical_bytes( + intrinsic_case, context, check_support, +): + intrinsic, with_verified_names = intrinsic_case + arguments = _arguments(intrinsic) + unknown = f"pf_unknown_{arguments[0][0]}" + positional = [value for _, value in arguments] + forms = ( + positional, + [f"{name} = {value}" for name, value in arguments], + [*positional, f"{unknown} = 2.0"], + ) + sources = [ + _source(context.format(expression=_call(intrinsic, form))) + for form in forms + ] + expected = [ + transpile(source, check_support=check_support).encode("utf-8") + for source in sources + ] + with with_verified_names(): + transpile(sources[0], check_support=check_support) + transpile(sources[1], check_support=check_support) + with pytest.raises(CompileError): + transpile(sources[2], check_support=check_support) + assert intrinsic.keyword_names_verified is False + assert [ + transpile(source, check_support=check_support).encode("utf-8") + for source in sources + ] == expected + + +@pytest.mark.parametrize("verified_short_name", ("sqrt", "exp")) +@pytest.mark.parametrize("context", ( + "{expression}", + 'request.security(syminfo.tickerid, "60", {expression})', +)) +@pytest.mark.parametrize("check_support", (True, False)) +def test_shared_parameter_names_resolve_each_callable_record( + monkeypatch, verified_short_name, context, check_support, +): + originals = { + short_name: signatures.MATH_FUNCTIONS[short_name] + for short_name in ("sqrt", "exp") + } + shared_name = "pf_shared_argument" + unknown = f"pf_unknown_{shared_name}" + records = { + short_name: replace( + original, + signatures=[replace( + original.primary, + params=[replace(original.primary.params[0], name=shared_name)], + )], + keyword_names_verified=short_name == verified_short_name, + ) + for short_name, original in originals.items() + } + with monkeypatch.context() as patch: + for short_name, record in records.items(): + patch.setitem(signatures.MATH_FUNCTIONS, short_name, record) + for short_name, record in records.items(): + positional = _source(context.format(expression=_call(record, ["2.0"]))) + expected = transpile(positional, check_support=check_support) + named = _source(context.format(expression=_call( + record, [f"{shared_name} = 2.0"], + ))) + assert _expressions(transpile( + named, check_support=check_support, + ), record) == _expressions(expected, record) + source = _source(context.format(expression=_call( + record, ["2.0", f"{unknown} = 3.0"], + ))) + if short_name == verified_short_name: + with pytest.raises(CompileError) as caught: + transpile(source, check_support=check_support) + diagnostic, = [ + diagnostic for diagnostic in caught.value.diagnostics + if diagnostic.level == Level.ERROR + ] + expected_args = {"key": record.name, "name": unknown} + assert diagnostic.code == "PF-E2027" + assert diagnostic.args == expected_args + assert (diagnostic.message, diagnostic.hint) == render_diagnostic( + "PF-E2027", expected_args, + ) + start = source.index(f"{unknown} =") + line = source.count("\n", 0, start) + 1 + col = start - source.rfind("\n", 0, start) + assert ( + diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col, + ) == (line, col, col + len(unknown)) + else: + before = transpile(source, check_support=check_support).encode("utf-8") + with monkeypatch.context() as verify: + verify.setattr(record, "keyword_names_verified", True) + with pytest.raises(CompileError): + transpile(source, check_support=check_support) + assert record.keyword_names_verified is False + assert transpile( + source, check_support=check_support, + ).encode("utf-8") == before + for short_name, original in originals.items(): + assert signatures.MATH_FUNCTIONS[short_name] is original diff --git a/tests/test_intrinsic_registry.py b/tests/test_intrinsic_registry.py new file mode 100644 index 00000000..905b5d9b --- /dev/null +++ b/tests/test_intrinsic_registry.py @@ -0,0 +1,40 @@ +"""Every intrinsic registry participates in keyword-name lookup.""" + +from pineforge_codegen import signatures +from pineforge_codegen.signatures import IntrinsicFunc + + +def test_every_intrinsic_registry_record_is_reachable(): + registries = { + name: registry + for name, registry in vars(signatures).items() + if isinstance(registry, dict) and registry + and all(isinstance(record, IntrinsicFunc) for record in registry.values()) + } + assert registries, "no intrinsic registries discovered" + for registry_name, registry in registries.items(): + for key, record in registry.items(): + namespace = None + if record.name != key: + suffix = f".{key}" + assert record.name.endswith(suffix), (registry_name, key, record.name) + namespace = record.name[:-len(suffix)] + assert signatures.get_intrinsic(namespace, key) is record, ( + registry_name, key, record.name, + ) + + +def test_registry_records_carry_no_evidence_fields(): + import dataclasses + import re + + banned = ("ta" + "pe", "dig" + "est", "sha" + "256") + for field in dataclasses.fields(IntrinsicFunc): + assert not any(word in field.name.lower() for word in banned), field.name + for name, value in vars(signatures).items(): + assert not any(word in name.lower() for word in banned), name + if isinstance(value, dict): + for record in value.values(): + if isinstance(record, IntrinsicFunc): + for item in dataclasses.astuple(record): + assert not re.search(r"\b[0-9a-f]{64}\b", repr(item)), record.name diff --git a/tests/test_signatures.py b/tests/test_signatures.py index ebedb871..4ddb3fb2 100644 --- a/tests/test_signatures.py +++ b/tests/test_signatures.py @@ -649,7 +649,7 @@ def test_math_sqrt_kwargs(self): assert "std::sqrt" in cpp def test_math_pow_kwargs(self): - cpp = _generate(_pine('x = math.pow(base=2.0, exp=3.0)')) + cpp = _generate(_pine('x = math.pow(base=2.0, exponent=3.0)')) assert "std::pow" in cpp def test_math_log_kwargs(self): From 947946b20564c3aaf128c248665db6029a15b88d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 12:38:44 +0800 Subject: [PATCH 05/14] Resolve explicit constant color input defaults Prove typed constant carriers through the input default contract and emit their packed values before script initialization. Share the existing color encoding and preserve direct input lowering and metadata. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 19 ++ CLAUDE.md | 19 ++ pineforge_codegen/codegen/color_constants.py | 89 ++++++++ pineforge_codegen/codegen/constant_values.py | 173 ++++++++++++++ pineforge_codegen/codegen/emit_top.py | 7 +- pineforge_codegen/codegen/input.py | 63 ++---- pineforge_codegen/codegen/visit_call.py | 16 +- pineforge_codegen/codegen/visit_expr.py | 24 +- pineforge_codegen/signatures.py | 30 ++- pineforge_codegen/support_checker.py | 6 + tests/test_const_input_defaults.py | 225 +++++++++++++++++++ tests/test_e2e_const_input_defaults.py | 89 ++++++++ 12 files changed, 665 insertions(+), 95 deletions(-) create mode 100644 pineforge_codegen/codegen/color_constants.py create mode 100644 pineforge_codegen/codegen/constant_values.py create mode 100644 tests/test_const_input_defaults.py create mode 100644 tests/test_e2e_const_input_defaults.py diff --git a/AGENTS.md b/AGENTS.md index 8c4ffe67..f2258674 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -559,6 +559,25 @@ with a warning. This exception covers only constructs PineForge refuses today. A form that compiles today keeps compiling: if its lowering is approximate, that is recorded as a defect and fixed by the correct lowering. A wrong value is never kept behind a warning, and such a form is never newly refused as unproven. A located refusal is allowed only as a labelled temporary fallback for one exact proven shape, listed as a compile gap. +Input defaults use the input family's Pine type and qualifier contract. +An explicit `const color` carrier with a proven initializer supplies its +packed value to the getter and settings descriptor before `on_bar`; the +manifest reads that descriptor. `codegen/constant_values.py` resolves lexical +bindings on the support checker's AST, after `rename_block_locals`, using +the existing bounded scalar and color evaluators. Reassigned, persistent and +inferred carriers remain compile gaps; an initializer alone cannot prove +the value at a later call. The proof applies in every statement context, +including function bodies and switch arms. Existing direct builders keep +their lowering and metadata unless their arguments read proven const +carriers; those use the same packed proof. Static input initialization uses +`_render_input_value`, as statement reads and settings descriptors do, so +it cannot read an uninitialized carrier member in place of that proof. + +If the required evidence and value checks are unavailable for a shape, the +existing located PF-E1006 is retained as a PineForge limitation and is listed +as a compile gap. A located refusal of a spelling TradingView rejects follows +its general grammar, type and qualifier rule. + The taxonomy: diff --git a/CLAUDE.md b/CLAUDE.md index 2f05d801..8d88db7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -559,6 +559,25 @@ with a warning. This exception covers only constructs PineForge refuses today. A form that compiles today keeps compiling: if its lowering is approximate, that is recorded as a defect and fixed by the correct lowering. A wrong value is never kept behind a warning, and such a form is never newly refused as unproven. A located refusal is allowed only as a labelled temporary fallback for one exact proven shape, listed as a compile gap. +Input defaults use the input family's Pine type and qualifier contract. +An explicit `const color` carrier with a proven initializer supplies its +packed value to the getter and settings descriptor before `on_bar`; the +manifest reads that descriptor. `codegen/constant_values.py` resolves lexical +bindings on the support checker's AST, after `rename_block_locals`, using +the existing bounded scalar and color evaluators. Reassigned, persistent and +inferred carriers remain compile gaps; an initializer alone cannot prove +the value at a later call. The proof applies in every statement context, +including function bodies and switch arms. Existing direct builders keep +their lowering and metadata unless their arguments read proven const +carriers; those use the same packed proof. Static input initialization uses +`_render_input_value`, as statement reads and settings descriptors do, so +it cannot read an uninitialized carrier member in place of that proof. + +If the required evidence and value checks are unavailable for a shape, the +existing located PF-E1006 is retained as a PineForge limitation and is listed +as a compile gap. A located refusal of a spelling TradingView rejects follows +its general grammar, type and qualifier rule. + The taxonomy: diff --git a/pineforge_codegen/codegen/color_constants.py b/pineforge_codegen/codegen/color_constants.py new file mode 100644 index 00000000..fff4835c --- /dev/null +++ b/pineforge_codegen/codegen/color_constants.py @@ -0,0 +1,89 @@ +"""The existing packed-color encoding, shared by defaults and constant proofs.""" + +import math + +from ..ast_nodes import ( + ColorLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, UnaryOp, +) +from .tables import _COLOR_CONSTANTS + + +COLOR_PARAMETERS = { + "new": ("color", "transp"), + "rgb": ("red", "green", "blue", "transp"), + "r": ("color",), "g": ("color",), "b": ("color",), "t": ("color",), +} + + +def color_arguments(name, call): + """Keep the getter's existing positional/keyword binding unchanged.""" + bound = list(call.args) + for index, parameter in enumerate(COLOR_PARAMETERS.get(name, ())): + if index >= len(call.args) and parameter in call.kwargs: + bound += [None] * (index + 1 - len(bound)) + bound[index] = call.kwargs[parameter] + return bound + + +def color_literal_to_int64(value: str) -> str: + """Lower #RRGGBB / #RRGGBBAA to the existing 0xAARRGGBBLL spelling.""" + h = value.lstrip("#").strip() + if len(h) == 6: + rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], "ff" + elif len(h) == 8: + rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], h[6:8] + else: + return "0" + try: + int(rr + gg + bb + aa, 16) + except ValueError: + return "0" + return f"0x{aa}{rr}{gg}{bb}LL" + + +def _signed_number(node): + if isinstance(node, NumberLiteral): + return node.value + if isinstance(node, UnaryOp) and node.op in ("+", "-"): + value = _signed_number(node.operand) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value if node.op == "+" else -value + return None + + +def literal_color_value(node) -> int | None: + """Read literal colors exactly as the input getter's original evaluator.""" + if isinstance(node, ColorLiteral): + return int(color_literal_to_int64(node.value).removesuffix("LL"), 16) + if isinstance(node, MemberAccess) and isinstance(node.object, Identifier): + constant = _COLOR_CONSTANTS.get(node.member) if node.object.name == "color" else None + return constant[1] if constant is not None else None + if not (isinstance(node, FuncCall) and isinstance(node.callee, MemberAccess) + and isinstance(node.callee.object, Identifier) + and node.callee.object.name == "color"): + return None + name = node.callee.member + arguments = color_arguments(name, node) + if any(argument is None for argument in arguments): + return None + if name == "new" and len(arguments) == 2: + base = literal_color_value(arguments[0]) + transparency = _signed_number(arguments[1]) + elif name == "rgb" and len(arguments) in (3, 4): + channels = [_signed_number(argument) for argument in arguments[:3]] + if not all(isinstance(value, (int, float)) and not isinstance(value, bool) + and (not isinstance(value, float) or math.isfinite(value)) + and -(1 << 63) < value < (1 << 63) for value in channels): + return None + red, green, blue = (int(value) & 0xFF for value in channels) + base = red << 16 | green << 8 | blue + transparency = _signed_number(arguments[3]) if len(arguments) == 4 else 0 + else: + return None + if (base is None or not isinstance(transparency, (int, float)) + or isinstance(transparency, bool) + or (isinstance(transparency, float) and not math.isfinite(transparency))): + return None + transparency = min(100, max(0, transparency)) + alpha = math.floor(255.0 * (100.0 - transparency) / 100.0 + 0.5) + return alpha << 24 | (base & 0xFFFFFF) diff --git a/pineforge_codegen/codegen/constant_values.py b/pineforge_codegen/codegen/constant_values.py new file mode 100644 index 00000000..0636bf90 --- /dev/null +++ b/pineforge_codegen/codegen/constant_values.py @@ -0,0 +1,173 @@ +"""Typed, lexical constant proofs for arguments with a constant contract.""" + +from collections import Counter +from dataclasses import dataclass, replace + +from ..ast_nodes import ( + ASTNode, BoolLiteral, ColorLiteral, FuncCall, Identifier, MemberAccess, + NumberLiteral, Ternary, VarDecl, +) +from ..limits import MAX_NESTING_DEPTH, TimeBudget, iter_ast_nodes +from ..security_contexts import ScriptIndex +from ..signatures import INPUT_FUNCTIONS, MATH_CONSTANTS +from ..symbols import PineType +from .color_constants import COLOR_PARAMETERS, color_arguments, literal_color_value +from .constant_fold import fold_constant_expression + + +@dataclass(frozen=True) +class ConstantValue: + pine_type: PineType + value: object + + +def input_default_contract(call): + callee = call.callee + if (isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier) + and callee.object.name == "input"): + intrinsic = INPUT_FUNCTIONS.get(callee.member) + return intrinsic.default_contract if intrinsic is not None else None + return None + + +class ConstantResolver: + """Resolve preceding explicit const declarations on this exact AST. + + Binding identity, rather than a variable's spelling, owns the cache. + Reassignment is deliberately unproven: a declaration's initial value is + insufficient evidence of its value at a later argument use. + """ + + def __init__(self, program, budget=None): + self.index = ScriptIndex(program) + self.budget = budget or TimeBudget("") + self.order = {} + declarations = Counter() + pending = [program] + while pending: + node = pending.pop() + if isinstance(node, ASTNode): + self.order[id(node)] = len(self.order) + if isinstance(node, VarDecl): + declarations[self.index.decl_binding.get((id(node), node.name))] += 1 + pending.extend(reversed([value for key, value in vars(node).items() + if key not in ("annotations", "loc")])) + elif isinstance(node, (list, tuple)): + pending.extend(reversed(node)) + elif isinstance(node, dict): + pending.extend(reversed(list(node.values()))) + self.cache = {} + self.active = set() + self.ambiguous = {binding for binding, count in declarations.items() if count > 1} + + def input_default(self, call): + contract = input_default_contract(call) + default = call.args[0] if call.args else call.kwargs.get("defval") + if (contract is None or contract.qualifier != "const" + or not isinstance(default, (Identifier, FuncCall))): + return None + # Literal-only builders retain their existing emission. A builder + # reading a binding needs the same lexical proof as a bare carrier: + # its member has not been assigned when static inputs initialize. + if not any(isinstance(node, Identifier) and id(node) in self.index.refs + for node, _ in iter_ast_nodes(default)): + return None + proof = self.expression(default) + return proof if proof is not None and proof.pine_type == contract.pine_type else None + + def _binding(self, read, depth): + binding = self.index.refs.get(id(read)) + declaration = self.index.decls.get(binding) + if (declaration is None or binding in self.index.unstable or binding in self.ambiguous + or (declaration.annotations or {}).get("qualifier") != "const" + or declaration.type_hint is None + or self.order[id(declaration)] >= self.order[id(read)] + or binding in self.active): + return None + if binding not in self.cache: + self.active.add(binding) + try: + value = self.expression(declaration.value, depth + 1) + if (value is not None and declaration.type_hint == "float" + and value.pine_type == PineType.INT): + value = ConstantValue(PineType.FLOAT, float(value.value)) + if value is not None and value.pine_type.value != declaration.type_hint: + value = None + self.cache[binding] = value + finally: + self.active.remove(binding) + return self.cache[binding] + + def _scalar(self, expr, depth): + known = {} + for node, _ in iter_ast_nodes(expr): + if isinstance(node, Identifier) and id(node) in self.index.refs: + value = self._binding(node, depth + 1) + if value is None or value.pine_type == PineType.COLOR: + return None + known[node.name] = value.value + value, proven = fold_constant_expression( + expr, known, {f"math.{name}": value for name, value in MATH_CONSTANTS.items()}) + kind = {bool: PineType.BOOL, int: PineType.INT, float: PineType.FLOAT, + str: PineType.STRING}.get(type(value)) + return ConstantValue(kind, value) if proven and kind is not None else None + + def expression(self, expr, depth=0): + if depth > MAX_NESTING_DEPTH or expr is None: + return None + self.budget.check(expr.loc) + if isinstance(expr, Identifier): + return self._binding(expr, depth) + if isinstance(expr, ColorLiteral): + return ConstantValue(PineType.COLOR, literal_color_value(expr)) + if (isinstance(expr, MemberAccess) and isinstance(expr.object, Identifier) + and id(expr.object) not in self.index.refs): + value = literal_color_value(expr) + if value is not None: + return ConstantValue(PineType.COLOR, value) + if isinstance(expr, Ternary): + condition = self._scalar(expr.condition, depth + 1) + left = self.expression(expr.true_val, depth + 1) + right = self.expression(expr.false_val, depth + 1) + if (condition is not None and condition.pine_type == PineType.BOOL + and left is not None and right is not None + and left.pine_type == right.pine_type == PineType.COLOR): + value, proven = fold_constant_expression(replace( + expr, condition=BoolLiteral(value=condition.value), + true_val=NumberLiteral(value=left.value), + false_val=NumberLiteral(value=right.value)), {}, {}) + return ConstantValue(PineType.COLOR, value) if proven else None + if (isinstance(expr, FuncCall) and isinstance(expr.callee, MemberAccess) + and isinstance(expr.callee.object, Identifier) + and expr.callee.object.name == "color" + and id(expr.callee.object) not in self.index.refs + and expr.callee.member in ("new", "rgb")): + name = expr.callee.member + parameters = COLOR_PARAMETERS[name] + if (len(expr.args) > len(parameters) + or any(parameter not in parameters + or parameters.index(parameter) < len(expr.args) + for parameter in expr.kwargs)): + return None + arguments = color_arguments(name, expr) + if not ((name == "new" and len(arguments) == 2) + or (name == "rgb" and len(arguments) in (3, 4))): + return None + folded = [] + for index, argument in enumerate(arguments): + proof = self.expression(argument, depth + 1) + if proof is None: + return None + if name == "new" and index == 0: + if proof.pine_type != PineType.COLOR: + return None + packed = proof.value + folded.append(ColorLiteral( + value=f"#{packed & 0xFFFFFF:06x}{packed >> 24:02x}")) + else: + if proof.pine_type not in (PineType.INT, PineType.FLOAT): + return None + folded.append(NumberLiteral(value=proof.value)) + value = literal_color_value(replace(expr, args=folded, kwargs={})) + return ConstantValue(PineType.COLOR, value) if value is not None else None + return self._scalar(expr, depth) diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 6d8a0ece..341f6f0b 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -1727,12 +1727,9 @@ def _emit_on_bar(self, lines: list[str]) -> None: ) if is_static_global_input: safe = self._safe_name(stmt.name) - default = self._get_input_default(stmt.value) - default_cpp = self._visit_expr(default) if default is not None else "0" title = self._get_input_title(stmt.value, var_name=stmt.name) - getter = self._input_getter_for_call(stmt.value, func_name_i, namespace_i) - default_cpp = self._coerce_string_input_default(getter, default_cpp) - cpp_val = f'{getter}({self._input_key_literal(title)}, {default_cpp})' + cpp_val = self._render_input_value( + stmt.value, func_name_i, namespace_i, title) static_vars.append(f"{safe} = {cpp_val};") if static_vars: diff --git a/pineforge_codegen/codegen/input.py b/pineforge_codegen/codegen/input.py index 5a914a5e..66d3c444 100644 --- a/pineforge_codegen/codegen/input.py +++ b/pineforge_codegen/codegen/input.py @@ -16,13 +16,10 @@ from __future__ import annotations -import math - from ..ast_nodes import ( ASTNode, BinOp, BoolLiteral, - ColorLiteral, FuncCall, Identifier, MemberAccess, @@ -33,8 +30,9 @@ ) from .. import signatures as sigs from ..pine_spelling import expr_start, global_input_calls, input_binding_names -from .tables import _COLOR_CONSTANTS -from .visit_expr import _color_literal_to_int64 +from ..symbols import PineType +from .color_constants import literal_color_value +from .constant_values import ConstantResolver, input_default_contract class InputHelper: @@ -372,13 +370,23 @@ def _render_input_value(self, node: FuncCall, func_name: str | None, return f'{getter}({self._input_key_literal(title)}, {default_cpp})' def _input_default_expression(self, node: FuncCall) -> tuple[str, int | None]: - """The getter's unchanged C++ default and its literal color value. + """The getter's C++ default and its proven packed color value. Both getter emission and the settings descriptor use this path; the manifest reads the descriptor's value rather than evaluating a second copy of the default. Unfoldable colors retain their previous metadata. """ default = self._get_input_default(node) + contract = input_default_contract(node) + if (contract is not None and contract.pine_type == PineType.COLOR + and isinstance(default, (Identifier, FuncCall))): + resolver = getattr(self, "_constant_default_resolver", None) + if resolver is None: + resolver = self._constant_default_resolver = ConstantResolver( + self.ctx.ast, getattr(self, "_budget", None)) + proof = resolver.input_default(node) + if proof is not None: + return f"0x{proof.value:08X}LL", proof.value cpp = self._visit_expr(default) if default is not None else "0" func_name, namespace = self._resolve_callee(node.callee) color = (self._literal_color_value(default) @@ -386,47 +394,8 @@ def _input_default_expression(self, node: FuncCall) -> tuple[str, int | None]: return cpp, color def _literal_color_value(self, node) -> int | None: - """Evaluate only literal colors using the getter's palette/binding. - - Match the existing color.hpp lowering: RGB channels truncate to int64 - and keep their low byte; transparency is clamped to 0..100 and alpha - rounds half up, not Python's ties-to-even. No user identifiers or - arithmetic are newly accepted or folded here. - """ - if isinstance(node, ColorLiteral): - return int(_color_literal_to_int64(node.value).removesuffix("LL"), 16) - if isinstance(node, MemberAccess) and isinstance(node.object, Identifier): - constant = _COLOR_CONSTANTS.get(node.member) if node.object.name == "color" else None - return constant[1] if constant is not None else None - if not isinstance(node, FuncCall): - return None - func_name, namespace = self._resolve_callee(node.callee) - if namespace != "color" or func_name not in ("new", "rgb"): - return None - arguments = self._color_arg_nodes(func_name, node) - if any(argument is None for argument in arguments): - return None - if func_name == "new" and len(arguments) == 2: - base = self._literal_color_value(arguments[0]) - transparency = self._literal_or_none(arguments[1], signed=True) - elif func_name == "rgb" and len(arguments) in (3, 4): - channels = [self._literal_or_none(argument, signed=True) for argument in arguments[:3]] - if not all(isinstance(value, (int, float)) and not isinstance(value, bool) - and (not isinstance(value, float) or math.isfinite(value)) - and -(1 << 63) < value < (1 << 63) for value in channels): - return None - red, green, blue = (int(value) & 0xFF for value in channels) - base = red << 16 | green << 8 | blue - transparency = self._literal_or_none(arguments[3], signed=True) if len(arguments) == 4 else 0 - else: - return None - if (base is None or not isinstance(transparency, (int, float)) - or isinstance(transparency, bool) - or (isinstance(transparency, float) and not math.isfinite(transparency))): - return None - transparency = min(100, max(0, transparency)) - alpha = math.floor(255.0 * (100.0 - transparency) / 100.0 + 0.5) - return alpha << 24 | (base & 0xFFFFFF) + """The unchanged literal evaluator, also used by constant proofs.""" + return literal_color_value(node) _GETTER_CPP_TYPE = { "get_input_bool": "bool", "get_input_int": "int", diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index a6f3dc04..fd5f3f27 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -164,6 +164,7 @@ signature_from_callable, ) from .. import signatures as sigs +from .color_constants import color_arguments from .drawing import ALL_DRAWING_METHODS from .helpers import ( color_alpha_cast, cpp_is_plain_read, evaluate_args_once, @@ -3745,25 +3746,12 @@ def _trade_index_cpp(self, node: FuncCall) -> str: _FROM_GRADIENT_PARAMS = ( "value", "bottom_value", "top_value", "bottom_color", "top_color", ) - # TradingView's parameter names of the other color.* functions. - _COLOR_PARAMS = { - "new": ("color", "transp"), - "rgb": ("red", "green", "blue", "transp"), - "r": ("color",), "g": ("color",), "b": ("color",), "t": ("color",), - } - def _color_arg_nodes(self, func_name: str, node) -> list: """The argument nodes of a ``color.*`` call in parameter order: the positional ones, then each keyword in its parameter's slot, up to the last one given (``color.new(transp = 40, color = c)`` is ``color.new(c, 40)``). A slot nothing fills is None.""" - params = self._COLOR_PARAMS.get(func_name, ()) - bound = list(node.args) - for index, name in enumerate(params): - if index >= len(node.args) and name in node.kwargs: - bound += [None] * (index + 1 - len(bound)) - bound[index] = node.kwargs[name] - return bound + return color_arguments(func_name, node) # Builtins whose call has no state and no side effect. _PURE_CALL_NAMESPACES = frozenset({"color", "math", "str"}) diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index d163fb33..cfbbfd8c 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -118,6 +118,7 @@ WhileStmt, ) from .strategy_declaration import DECLARATION_CONSTANTS, NamedConstant +from .color_constants import color_literal_to_int64 as _color_literal_to_int64 from .helpers import pine_index_int_cast, unary_sign_cpp from .types import COLLECTION_MUTATING_METHODS from .tables import ( @@ -140,29 +141,6 @@ ) -def _color_literal_to_int64(value: str) -> str: - """Lower a Pine hex color literal to a packed ARGB int64 C++ literal. - - Pine ``#RRGGBB`` is opaque; ``#RRGGBBAA`` carries an explicit alpha - (opacity, FF = opaque). The engine packs colors as ``0xAARRGGBB`` - (see color.hpp: ``red == 0xFFFF0000``), so we reorder to alpha-first - and emit an ``int64_t`` literal — matching ``get_input_int64`` and - ``pine_color::*``. A malformed literal falls back to 0 (transparent). - """ - h = value.lstrip("#").strip() - if len(h) == 6: - rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], "ff" - elif len(h) == 8: - rr, gg, bb, aa = h[0:2], h[2:4], h[4:6], h[6:8] - else: - return "0" - try: - int(rr + gg + bb + aa, 16) # validate hex - except ValueError: - return "0" - return f"0x{aa}{rr}{gg}{bb}LL" - - # Valid Pine v6 bare builtins that have no value in a batch backtest (no live # feed). Reading them used to emit an undeclared C++ symbol; they now get a # tailored hint via the unknown-identifier guard in ``_visit_ident``. diff --git a/pineforge_codegen/signatures.py b/pineforge_codegen/signatures.py index 0ec84700..2872c18f 100644 --- a/pineforge_codegen/signatures.py +++ b/pineforge_codegen/signatures.py @@ -35,6 +35,13 @@ class FuncSig: tuple_count: int = 0 # number of tuple elements +@dataclass(frozen=True) +class InputDefaultContract: + """The Pine base type and qualifier required of an input default.""" + pine_type: PineType + qualifier: str = "const" + + @dataclass class IntrinsicFunc: """Complete intrinsic function definition with all overloads.""" @@ -43,6 +50,7 @@ class IntrinsicFunc: # Declared parameter names are the complete, verified keyword set. # False preserves the record's existing keyword policy. keyword_names_verified: bool = False + default_contract: InputDefaultContract | None = None @property def primary(self) -> FuncSig: @@ -497,8 +505,14 @@ def _builtin(name: str, *sigs: FuncSig) -> None: INPUT_FUNCTIONS: dict[str, IntrinsicFunc] = {} -def _input(short_name: str, *sigs: FuncSig) -> None: - INPUT_FUNCTIONS[short_name] = _func(f"input.{short_name}", *sigs) +def _input(short_name: str, *sigs: FuncSig, + default_type: PineType | None = None, default_qualifier: str = "const") -> None: + intrinsic = _func(f"input.{short_name}", *sigs) + intrinsic.default_contract = InputDefaultContract( + default_type if default_type is not None else sigs[0].params[0].pine_type, + default_qualifier, + ) + INPUT_FUNCTIONS[short_name] = intrinsic # Common trailing: tooltip, inline, group, display (plot_display), confirm _C = [("tooltip", S, None), ("inline", S, None), ("group", S, None), @@ -510,16 +524,20 @@ def _input(short_name: str, *sigs: FuncSig) -> None: ("maxval", F, None), ("step", F, None)] + _C, ret=F)) _input("bool", _sig([("defval", B), ("title", S, "")] + _C, ret=B)) _input("string", _sig([("defval", S), ("title", S, ""), ("options", S, None)] + _C, ret=S)) -_input("source", _sig([("defval", F), ("title", S, "")] + _C, ret=F)) -_input("color", _sig([("defval", I), ("title", S, "")] + _C, ret=I)) +_input("source", _sig([("defval", F), ("title", S, "")] + _C, ret=F), + default_qualifier="series") +_input("color", _sig([("defval", I), ("title", S, "")] + _C, ret=I), + default_type=PineType.COLOR) _input("timeframe", _sig([("defval", S, ""), ("title", S, "")] + _C, ret=S)) _input("session", _sig([("defval", S, ""), ("title", S, "")] + _C, ret=S)) _input("symbol", _sig([("defval", S, ""), ("title", S, "")] + _C, ret=S)) _input("price", _sig([("defval", F), ("title", S, "")] + _C, ret=F)) _input("time", _sig([("defval", I), ("title", S, "")] + _C, ret=I)) _input("text_area", _sig([("defval", S), ("title", S, "")] + _C, ret=S)) -# defval is an enum member (typed as float in Pine's overloads; value is int index) -_input("enum", _sig([("defval", F), ("title", S, "")] + _C, ret=I)) +# The erased signature uses float; the default must carry a declared enum +# identity, which the existing enum resolver checks, not a primitive proof. +_input("enum", _sig([("defval", F), ("title", S, "")] + _C, ret=I), + default_type=PineType.UNKNOWN) # ============================================================================ diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index ca3da8b4..8974624d 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -74,6 +74,7 @@ LINE_METHODS, LINE_NOOP, BOX_METHODS, BOX_NOOP, LABEL_METHODS, LABEL_NOOP, LINEFILL_METHODS, LINEFILL_NOOP, ) +from .codegen.constant_values import ConstantResolver # --------------------------------------------------------------------------- @@ -1311,6 +1312,11 @@ def _check_input_color_defval(self, node: FuncCall) -> None: ) return if not self._is_color_constant_or_builder(defval): + resolver = getattr(self, "_constant_default_resolver", None) + if resolver is None: + resolver = self._constant_default_resolver = ConstantResolver(self._ast) + if resolver.input_default(node) is not None: + return self._err( node, "input.color(...) defval must be a color constant " diff --git a/tests/test_const_input_defaults.py b/tests/test_const_input_defaults.py new file mode 100644 index 00000000..738e8d94 --- /dev/null +++ b/tests/test_const_input_defaults.py @@ -0,0 +1,225 @@ +"""Constant input defaults are values before any script statement executes.""" + +import json +import re + +import pytest + +from pineforge_codegen import transpile, transpile_full +from pineforge_codegen.codegen.tables import COLOR_CONST_MAP +from pineforge_codegen.errors import CompileError +from pineforge_codegen.lexer import Lexer +from pineforge_codegen.limits import iter_ast_nodes +from pineforge_codegen.parser import Parser + + +COLOR_CASES = [ + ("member", "const color seed = color.blue", "color.blue"), + ("other_member", "const color seed = color.red", "color.red"), + ("hex", "const color seed = #336699", "#336699"), + ("hex_alpha", "const color seed = #33669980", "#33669980"), + ("new", "const color seed = color.new(color.blue, 75)", "color.new(color.blue, 75)"), + ("rgb", "const color seed = color.rgb(10, 20, 30, 40)", "color.rgb(10, 20, 30, 40)"), + ("alias", "const color origin = color.blue\nconst color seed = origin", "color.blue"), + ("arithmetic", "const int step = 25\nconst color seed = color.new(color.blue, step + 25)", + "color.new(color.blue, 50)"), + ("selection", "const bool pick = true\nconst color seed = pick ? color.blue : color.red", + "color.blue"), +] + +BUILDER_CASES = [ + ("new_base", "const color seed = color.blue", + "color.new(seed, 50)", "color.new(color.blue, 50)"), + ("new_keywords", "const color seed = color.blue", + "color.new(transp=50, color=seed)", "color.new(transp=50, color=color.blue)"), + ("new_transparency", "const int seed = 50", + "color.new(color.blue, seed)", "color.new(color.blue, 50)"), + ("rgb_channel", "const int seed = 10", + "color.rgb(seed, 20, 30, 40)", "color.rgb(10, 20, 30, 40)"), + ("nested_builder", "const color seed = color.blue", + "color.new(color.new(seed, 75), 50)", "color.new(color.new(color.blue, 75), 50)"), +] + +DEFAULT_CASES = [(label, declarations, "seed", direct) + for label, declarations, direct in COLOR_CASES] + BUILDER_CASES + + +def source(declarations, default="seed", *, named=False, context="global"): + call = f'input.color({"defval=" if named else ""}{default}, title="Tint")' + if context == "function": + body = f"{declarations}\nchoose() =>\n {call}\nshade = choose()" + elif context == "function_local": + body = ("choose() =>\n" + + "\n".join(f" {line}" for line in declarations.splitlines()) + + f"\n {call}\nshade = choose()") + elif context == "switch": + body = ("shade = switch 1\n 1 =>\n" + + "\n".join(f" {line}" for line in declarations.splitlines()) + + f"\n {call}\n => color.red") + elif context == "if": + body = (f"{declarations}\nshade = if true\n {call}\nelse\n color.red") + elif context == "loop": + body = (f"{declarations}\ncolor shade = color.red\n" + f"for index = 0 to 0\n shade := {call}") + else: + body = f"{declarations}\nshade = {call}" + return f'//@version=6\nstrategy("Constant defaults")\n{body}\nplot(close)\n' + + +def direct_value(expression): + return transpile_full(source("", expression))["inputs"][0]["default"] + + +def getter_defaults(cpp): + values = re.findall(r'get_input_int64\("Tint", (0x[0-9a-fA-F]+|\d+)LL\)', cpp) + assert values, "the default must be a packed constant before on_bar" + # Count every call, including variable/builder arguments the literal regex + # cannot match. A correct settings getter must not hide a wrong body getter. + assert len(values) == cpp.count('get_input_int64("Tint", '), ( + "every getter, including static input initialization, needs the packed default") + return [int(value, 0) for value in values] + + +def test_direct_color_control_keeps_its_packed_engine_constant(): + pine = source("", "color.blue") + full = transpile_full(pine) + assert full["cpp"] == transpile(pine) + assert f'get_input_int64("Tint", {COLOR_CONST_MAP["blue"]})' in full["cpp"] + assert type(full["inputs"][0]["default"]) is int + + +@pytest.mark.parametrize("named", [False, True], ids=["positional", "named"]) +@pytest.mark.parametrize("label,declarations,direct", COLOR_CASES, ids=[row[0] for row in COLOR_CASES]) +def test_explicit_const_defaults_equal_the_direct_expression(label, declarations, direct, named): + pine = source(declarations, named=named) + cpp = transpile(pine) + full = transpile_full(pine) + expected = direct_value(direct) + assert getter_defaults(cpp) == [expected] * len(getter_defaults(cpp)) + assert full["cpp"] == cpp + assert full["inputs"] == [{ + "title": "Tint", "type": "color", "default": expected, "supported": True, + }] + + +@pytest.mark.parametrize("named", [False, True], ids=["positional", "named"]) +@pytest.mark.parametrize("context", ["global", "function", "function_local", "switch"]) +@pytest.mark.parametrize("label,declarations,default,direct", DEFAULT_CASES, + ids=[row[0] for row in DEFAULT_CASES]) +def test_every_emitted_getter_matches_a_direct_default( + label, declarations, default, direct, context, named): + reference = transpile_full(source("", direct)) + packed = reference["inputs"][0]["default"] + # Emit a direct literal for the reference expression's color so both + # getters have numeric defaults we can compare without a C++ evaluator. + literal = f"#{packed & 0xFFFFFF:06X}{packed >> 24:02X}" + expected = getter_defaults(transpile(source("", literal))) + assert all(value == packed for value in expected) + + full = transpile_full(source(declarations, default, named=named, context=context)) + actual = getter_defaults(full["cpp"]) + assert actual == [expected[0]] * len(actual) + if context == "global": + assert full["inputs"] == reference["inputs"] + # Check the descriptor's default too, independently of its effective + # getter and the Python manifest. + descriptor = next(line for line in full["cpp"].splitlines() + if '{"Tint", "int", ' in line) + value = re.search(r'checked_settings::number\((0x[0-9a-fA-F]+|\d+)LL\)', + descriptor) + assert value is not None + assert int(value.group(1), 0) == expected[0] + + +@pytest.mark.parametrize("context", ["function", "switch", "if", "loop"]) +def test_const_proof_applies_in_each_statement_context(context): + pine = source("const color seed = color.blue", context=context) + full = transpile_full(pine) + expected = direct_value("color.blue") + assert all(value == expected for value in getter_defaults(full["cpp"])) + assert not any(d.code in ("PF-E1006", "PF-W1006") for d in full["diagnostics"]) + + +@pytest.mark.parametrize("name", ["firstShade", "alternativeTint"]) +@pytest.mark.parametrize("member", ["blue", "red"]) +def test_binding_names_do_not_select_the_constant_value(name, member): + pine = source(f"const color {name} = color.{member}", name) + assert all(value == direct_value(f"color.{member}") + for value in getter_defaults(transpile(pine))) + + +def test_reassigned_default_never_uses_its_initializer(): + # TradingView accepts this and reads red. A located limitation is allowed + # until value flow is implemented; it is a compile gap, not a language rule. + pine = source("color seed = color.blue\nseed := color.red") + try: + full = transpile_full(pine) + except CompileError as exc: + errors = [d for d in exc.diagnostics if d.code == "PF-E1006"] + assert len(errors) == 1 + assert errors[0].location == input_call_location(pine) + else: + assert all(value == direct_value("color.red") + for value in getter_defaults(full["cpp"])) + assert full["inputs"][0]["default"] == direct_value("color.red") + + +def input_call_location(pine): + ast = Parser(Lexer(pine).tokenize()).parse() + return next(node.loc for node, _ in iter_ast_nodes(ast) + if getattr(getattr(node, "callee", None), "member", None) == "color") + + +# These forms were refused by both TradingView and the public transpiler. +# Existing accepted builders with nonconstant arguments are separate defects. +@pytest.mark.parametrize("declarations,default", [ + ("simple color seed = color.blue", "seed"), + ("series color seed = color.blue", "seed"), + ('seed = input.color(color.blue, title="Seed")', "seed"), + ('pick = input.bool(true, title="Pick")', "pick ? color.blue : color.red"), + ("", "close >= open ? color.blue : color.red"), + ("const color seed = close >= open ? color.blue : color.red", "seed"), + ("const int seed = 7", "seed"), + ('const string seed = "blue"', "seed"), + ('choose(color arg) =>\n input.color(arg, title="Tint")\n' + 'const color seed = color.blue', "seed"), + ('choose(simple color arg) =>\n input.color(arg, title="Tint")\n' + 'const color seed = color.blue', "seed"), + ('choose(arg) =>\n input.color(arg, title="Tint")\n' + 'const color seed = color.blue', "seed"), + ("type Holder\n color tint\nconst color seed = color.blue\n" + "Holder holder = Holder.new(seed)", "holder.tint"), +]) +def test_invalid_type_or_qualifier_keeps_a_located_refusal(declarations, default): + pine = source(declarations, default) + with pytest.raises(CompileError) as caught: + transpile(pine) + assert any(d.code == "PF-E1006" and d.location.line > 0 + and d.location.col > 0 for d in caught.value.diagnostics) + + +def test_a_forward_reference_does_not_borrow_a_later_constant(): + pine = ('//@version=6\nstrategy("Constant defaults")\n' + 'shade = input.color(seed, title="Tint")\nconst color seed = color.blue\n') + with pytest.raises(CompileError) as caught: + transpile(pine) + assert any(d.code == "PF-E1006" for d in caught.value.diagnostics) + + +def test_a_parameter_cannot_borrow_a_global_constant_proof(): + pine = ('//@version=6\nstrategy("Constant defaults")\n' + 'const color seed = color.blue\n' + 'choose(color seed) =>\n input.color(seed, title="Tint")\n' + 'shade = choose(seed)\n') + with pytest.raises(CompileError) as caught: + transpile(pine) + assert any(d.code == "PF-E1006" for d in caught.value.diagnostics) + + +def test_other_input_types_keep_the_existing_constant_lowering(): + prefix = '//@version=6\nstrategy("Constant defaults")\nconst int seed = 7\n' + carrier = prefix + 'period = input.int(seed, title="Period")\nplot(period)\n' + direct = prefix + 'period = input.int(7, title="Period")\nplot(period)\n' + before, after = transpile_full(direct), transpile_full(carrier) + assert before["cpp"] == after["cpp"] + assert json.dumps(before["inputs"]) == json.dumps(after["inputs"]) diff --git a/tests/test_e2e_const_input_defaults.py b/tests/test_e2e_const_input_defaults.py new file mode 100644 index 00000000..210b8be9 --- /dev/null +++ b/tests/test_e2e_const_input_defaults.py @@ -0,0 +1,89 @@ +"""Read constant color defaults through native settings and order quantities.""" + +import json +from decimal import Decimal + +import pytest + +from pineforge_codegen import transpile_full +from tests._compile import run_emitted_tu +from tests._e2e import ( + build_strategy_library, chart_feed_head, closed_trades, skip_unless_e2e_env, +) +from tests.test_const_input_defaults import DEFAULT_CASES, direct_value, source +from tests.test_string_settings_constants import RECEIPT_DRIVER + + +def readout(pine): + return pine + "".join( + f'if bar_index == {index}\n' + f' strategy.entry("channel_{channel}", strategy.long, ' + f'qty=1 + color.{channel}(shade) / 1000)\n' + f'if bar_index == {index + 1}\n' + f' strategy.close("channel_{channel}")\n' + for index, channel in zip((9, 19, 29, 39), ("r", "g", "b", "t")) + ) + + +@pytest.mark.parametrize("label,declarations,default,direct", DEFAULT_CASES, + ids=[row[0] for row in DEFAULT_CASES]) +def test_native_receipt_resolves_the_default_before_on_bar(label, declarations, default, direct): + full = transpile_full(source(declarations, default)) + expected = direct_value(direct) + # Settings inspection alone never executes the static input preamble. + # Run a bar without overrides and inspect the value the script actually + # reads, as well as the receipt obtained before the bar. + driver = RECEIPT_DRIVER.replace(" strategy_free(strategy);", r''' + Bar bars[]{{100, 101, 99, 100, 1000, 1577836800000LL}}; + ReportC report{}; + assert(run_backtest_full_checked(strategy, bars, 1, "1", "1", 0, 4, 0, + &report, error, sizeof(error)) == PF_SETTINGS_OK); + assert(error[0] == '\0'); + assert(static_cast(strategy)->shade == EXPECTED_DEFAULT); + report_free(&report); + strategy_free(strategy); +''').replace("EXPECTED_DEFAULT", f"{expected}LL") + receipt = json.loads(run_emitted_tu(full["cpp"], driver, opt="-O0", + label="constant color input receipt and execution")) + assert int(receipt["inputs"][0]["default"]) == expected + assert full["inputs"][0]["default"] == expected + + +@pytest.fixture(scope="module") +def native_color_feed(tmp_path_factory): + root = skip_unless_e2e_env() + feed = chart_feed_head(root, tmp_path_factory.mktemp("constant_color_feed"), 48) + return root, feed + + +@pytest.mark.parametrize("named,context", [ + (False, "global"), (True, "global"), (False, "function"), + (False, "function_local"), (False, "switch"), (False, "if"), (False, "loop"), +]) +@pytest.mark.parametrize("member", ["blue", "red"]) +def test_native_channel_trades_and_overrides_match_the_direct_default( + tmp_path, native_color_feed, named, context, member): + root, feed = native_color_feed + candidate = transpile_full(readout(source( + f"const color seed = color.{member}", named=named, context=context))) + control = transpile_full(readout(source("", f"color.{member}"))) + results = [] + override = direct_value("color.red" if member == "blue" else "color.blue") + for name, full in (("carrier", candidate), ("direct", control)): + work = tmp_path / name + work.mkdir() + build_strategy_library(full["cpp"], work) + metadata = {"mincontract": 0.00001} + defaults = closed_trades(root, work, feed, syminfo_metadata=metadata) + assert len(defaults) == 4 + # Function/switch inputs have no exported settings descriptor on the + # existing path. Check overrides on the exported global input. + overridden = None + if context == "global": + overridden = closed_trades(root, work, feed, {"Tint": str(override)}, + syminfo_metadata=metadata) + assert len(overridden) == 4 + assert any(Decimal(str(a["qty"])) != Decimal(str(b["qty"])) + for a, b in zip(defaults, overridden)) + results.append((defaults, overridden)) + assert results[0] == results[1] From 0797d75c08ee799ac57a3675ace1fd42c6a803bf Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 04:10:30 +0800 Subject: [PATCH 06/14] lexer: a line after an unfinished operator continues the statement only when its indent is not a multiple of four, at every block depth Measured on TradingView: multiples of four are refused (top level and inside bodies), non-multiples and parenthesised wraps are accepted. Replaces the 0/4/8 width list. Co-Authored-By: Claude Sonnet 5.5 --- pineforge_codegen/lexer.py | 12 ++- tests/test_continuation_context.py | 95 ++++++++++++++++------- tests/test_continuation_indent_rules.py | 4 +- tests/test_continuation_widths.py | 97 ++++++++++++++++++++++++ tests/test_indentation_refusals_extra.py | 52 ++++++++++--- tests/test_matrix_na_lifecycle.py | 6 +- 6 files changed, 216 insertions(+), 50 deletions(-) create mode 100644 tests/test_continuation_widths.py diff --git a/pineforge_codegen/lexer.py b/pineforge_codegen/lexer.py index e430caa6..bf14b1aa 100644 --- a/pineforge_codegen/lexer.py +++ b/pineforge_codegen/lexer.py @@ -415,17 +415,15 @@ def _tokenize_line(self) -> None: self._emit(TokenType.NEWLINE, "\\n", comma.line, 1) self._in_continuation = False if (self._in_continuation - and self.indent_stack[-1] == 0 - and not self._logical_line_has_arrow and self.tokens[-1].type != TokenType.COMMA - and raw in ("", " ", " ")): - # The measured restriction is on top-level operands at zero or - # four or eight spaces. Block and callable bodies retain their wraps. - last_token = self.tokens[-1] + and "\t" not in raw + and not continues_by_indent): + # Multiples of four are structural indentation at every block + # depth; only a nonmultiple can continue an unfinished expression. self._emit_diagnostic( "Line continuation outside parentheses or brackets requires " "space indentation that is not a multiple of four.", - last_token.line, last_token.end_col, last_token.end_col, + self.line, self.col, self.col + 1, ) # Operator-first line continuation: when a line *begins* with a binary diff --git a/tests/test_continuation_context.py b/tests/test_continuation_context.py index 76e12864..e80a4294 100644 --- a/tests/test_continuation_context.py +++ b/tests/test_continuation_context.py @@ -1,4 +1,4 @@ -"""Continuation restrictions distinguish script statements from callable bodies.""" +"""Operand indentation has the same meaning in script and callable bodies.""" import pytest @@ -7,17 +7,17 @@ from tests._compile import run_emitted_tu -_CASES = [ +_REFUSED_CASES = [ pytest.param( "evaluate() =>\n 1.0 +\n 2.0\nactual = evaluate()\n", - "3", - # The aligned operand belongs to 1 + 2, so the function returns 3. + (5, 5), + # Four spaces are structural; the operand starts at line 5, column 5. id="aligned_function_operand", ), pytest.param( "evaluate() =>\n 1.0 +\n 2.0\nactual = evaluate()\n", - "3", - # The deeper operand still belongs to 1 + 2, giving 3. + (5, 9), + # Eight spaces are structural; the operand starts at line 5, column 9. id="deeper_function_operand", ), pytest.param( @@ -28,8 +28,8 @@ " else\n" " 0.0\n" "actual = evaluate(4.0)\n", - "7", - # The true branch returns 4 + 3, which is 7. + (6, 13), + # Twelve spaces are structural even inside the function's nested block. id="nested_block_operand", ), pytest.param( @@ -37,8 +37,8 @@ " enabled ? 7.0 :\n" " 2.0\n" "actual = evaluate(false)\n", - "2", - # The false ternary arm is the continued literal 2. + (5, 9), + # Eight spaces cannot continue a ternary's false operand. id="block_ternary", ), pytest.param( @@ -47,24 +47,16 @@ " price < 10\n" " ready ? 9.0 : 0.0\n" "actual = evaluate(4.0)\n", - "9", - # Both comparisons are true for 4, selecting 9. + (5, 9), + # Eight spaces cannot continue the right operand of and. id="block_logical_chain", ), - pytest.param( - "evaluate(int value) => value + 1, value * 2,\n" - " value * 3\n" - "actual = evaluate(4)\n", - "12", - # The comma body returns its last statement, 4 * 3 = 12. - id="inline_comma_body", - ), pytest.param( "evaluate(float price) => price +\n" " 3.0\n" "actual = evaluate(4.0)\n", - "7", - # The arrow opens a callable body whose expression is 4 + 3 = 7. + (4, 5), + # An inline arrow does not exempt an operand at four spaces. id="inline_arithmetic_body", ), pytest.param( @@ -73,12 +65,59 @@ " 1.0) +\n" " 3.0\n" "actual = evaluate(4.0)\n", - "7", - # The closed call leaves a pending plus in the block: max(4, 1) + 3 = 7. + (6, 9), + # Once the call closes, the plus requires nonmultiple indentation. id="closed_call_in_block", ), + pytest.param( + "method doubled(float self) => self *\n" + " 2.0\n" + "actual = close.doubled()\n", + (4, 9), + # A method's trailing multiplication also requires nonmultiple spaces. + id="inline_method_operand", + ), ] +# Unchanged acceptance pins for nonmultiple operand widths and statement commas. +_CASES = [ + pytest.param( + "evaluate() =>\n 1.0 +\n 2.0\nactual = evaluate()\n", + "3", + # Five spaces continue 1 + 2 in the function's block, giving 3. + id="five_space_function_operand", + ), + pytest.param( + "evaluate(float price) => price +\n" + " 3.0\n" + "actual = evaluate(4.0)\n", + "7", + # Six spaces continue the inline body's 4 + 3, giving 7. + id="six_space_inline_operand", + ), + pytest.param( + "evaluate(int value) => value + 1, value * 2,\n" + " value * 3\n" + "actual = evaluate(4)\n", + "12", + # Unchanged pin: the comma body returns its last statement, 4 * 3 = 12. + id="inline_comma_body", + ), +] + + +@pytest.mark.parametrize("body, position", _REFUSED_CASES) +def test_callable_operand_requires_nonmultiple_indentation( + body: str, position: tuple[int, int], +) -> None: + source = '//@version=6\nstrategy("Callable continuation")\n' + body + with pytest.raises(CompileError) as caught: + transpile(source) + (diagnostic,) = caught.value.diagnostics + assert diagnostic.phase == Phase.LEXER + assert diagnostic.code == "PF-E0038" + assert (diagnostic.location.line, diagnostic.location.col) == position + _DRIVER = r""" #include @@ -115,7 +154,7 @@ def test_callable_wraps_keep_their_hand_derived_value(body: str, expected: str) def test_callable_wrap_does_not_exempt_the_next_script_statement(operand: str) -> None: source = ( '//@version=6\nstrategy("Continuation boundary")\n' - "evaluate() =>\n 1.0 +\n 2.0\n" + "evaluate() =>\n 1.0 +\n 2.0\n" "q = 1.0 +\n" + operand + "\n" ) with pytest.raises(CompileError) as caught: @@ -123,5 +162,7 @@ def test_callable_wrap_does_not_exempt_the_next_script_statement(operand: str) - (diagnostic,) = caught.value.diagnostics assert diagnostic.phase == Phase.LEXER assert diagnostic.code == "PF-E0038" - # The callable wrap is accepted; only the script's plus ends at 6:10. - assert (diagnostic.location.line, diagnostic.location.col) == (6, 10) + # The six-space callable wrap is valid; the next operand is on line 7. + assert (diagnostic.location.line, diagnostic.location.col) == ( + 7, len(operand) - len(operand.lstrip()) + 1, + ) diff --git a/tests/test_continuation_indent_rules.py b/tests/test_continuation_indent_rules.py index 87c8b123..918319ee 100644 --- a/tests/test_continuation_indent_rules.py +++ b/tests/test_continuation_indent_rules.py @@ -572,7 +572,7 @@ if bar_index % 20 == 15 strategy.close("L") """, - (3, 10), # Column zero cannot continue the plus, which ends at column 10. + (4, 1), # Column zero cannot continue the plus; the operand is at 4:1. id="column_zero_operand", ), pytest.param( @@ -585,7 +585,7 @@ if bar_index % 20 == 15 strategy.close("L") """, - (3, 10), # Four spaces cannot continue the plus, which ends at column 10. + (4, 5), # Four spaces cannot continue the plus; the operand is at 4:5. id="four_space_operand", ), pytest.param( diff --git a/tests/test_continuation_widths.py b/tests/test_continuation_widths.py new file mode 100644 index 00000000..40202fbe --- /dev/null +++ b/tests/test_continuation_widths.py @@ -0,0 +1,97 @@ +"""TradingView operand and call wraps at each measured indentation width.""" + +from pathlib import Path + +import pytest + +from tests._e2e import transpile_json + + +_HEADER = ( + '//@version=6\n' + 'strategy("p1", overlay = true, pyramiding = 0, ' + 'default_qty_type = strategy.fixed, default_qty_value = 1, ' + 'initial_capital = 1000000000, process_orders_on_close = false)\n' +) +_TAIL = ( + 'plot(a)\n' + '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' + 'plot(close, "p")\n' +) +_CONTEXTS = { + "top": ("float a = close +\n", "open\n", 4), + "block": ( + "float a = 0.0\nif bar_index % 2 == 0\n a := close +\n", + "open\n", 6, + ), + "call": ("float a = math.max(close,\n", "open)\n", 4), + "decl_call_block": ( + "float a = 0.0\nif bar_index % 2 == 0\n a := math.max(close,\n", + "open)\n", 6, + ), +} + +# Accepted rows are explicit unchanged pins. Refused rows fail before the +# change: either the lexer accepted them or it located the preceding operator. +_CASES = [ + pytest.param("top", 1, True, id="cont_top_1"), # 1 % 4 = 1: operand joins. + pytest.param("top", 2, True, id="cont_top_2"), # 2 % 4 = 2: operand joins. + pytest.param("top", 3, True, id="cont_top_3"), # 3 % 4 = 3: operand joins. + pytest.param("top", 4, False, id="cont_top_4"), # 4 % 4 = 0: structural. + pytest.param("top", 5, True, id="cont_top_5"), # 5 % 4 = 1: operand joins. + pytest.param("top", 6, True, id="cont_top_6"), # 6 % 4 = 2: operand joins. + pytest.param("top", 7, True, id="cont_top_7"), # 7 % 4 = 3: operand joins. + pytest.param("top", 8, False, id="cont_top_8"), # 8 % 4 = 0: structural. + pytest.param("top", 9, True, id="cont_top_9"), # 9 % 4 = 1: operand joins. + pytest.param("top", 11, True, id="cont_top_11"), # 11 % 4 = 3: operand joins. + pytest.param("top", 12, False, id="cont_top_12"), # 12 % 4 = 0: structural. + pytest.param("top", 13, True, id="cont_top_13"), # 13 % 4 = 1: operand joins. + pytest.param("top", 15, True, id="cont_top_15"), # 15 % 4 = 3: operand joins. + pytest.param("top", 16, False, id="cont_top_16"), # 16 % 4 = 0: structural. + pytest.param("top", 17, True, id="cont_top_17"), # 17 % 4 = 1: operand joins. + pytest.param("top", 20, False, id="cont_top_20"), # 20 % 4 = 0: structural. + pytest.param("top", 24, False, id="cont_top_24"), # 24 % 4 = 0: structural. + pytest.param("block", 5, True, id="cont_block_5"), # 5 % 4 = 1: operand joins. + pytest.param("block", 6, True, id="cont_block_6"), # 6 % 4 = 2: operand joins. + pytest.param("block", 8, False, id="cont_block_8"), # 8 % 4 = 0: structural. + pytest.param("block", 9, True, id="cont_block_9"), # 9 % 4 = 1: operand joins. + pytest.param("block", 12, False, id="cont_block_12"), # 12 % 4 = 0: structural. + pytest.param("block", 13, True, id="cont_block_13"), # 13 % 4 = 1: operand joins. + pytest.param("block", 16, False, id="cont_block_16"), # 16 % 4 = 0: structural. + pytest.param("block", 17, True, id="cont_block_17"), # 17 % 4 = 1: operand joins. + pytest.param("block", 20, False, id="cont_block_20"), # 20 % 4 = 0: structural. + pytest.param("call", 0, True, id="cont_call_0"), # Open call ignores width 0. + pytest.param("call", 1, True, id="cont_call_1"), # Open call ignores width 1. + pytest.param("call", 4, True, id="cont_call_4"), # Open call ignores width 4. + pytest.param("call", 8, True, id="cont_call_8"), # Open call ignores width 8. + pytest.param("call", 12, True, id="cont_call_12"), # Open call ignores width 12. + pytest.param("call", 16, True, id="cont_call_16"), # Open call ignores width 16. + pytest.param("decl_call_block", 0, True, id="cont_decl_call_block_0"), # Open call ignores width 0. + pytest.param("decl_call_block", 2, True, id="cont_decl_call_block_2"), # Open call ignores width 2. + pytest.param("decl_call_block", 4, True, id="cont_decl_call_block_4"), # Open call ignores width 4. + pytest.param("decl_call_block", 8, True, id="cont_decl_call_block_8"), # Open call ignores width 8. + pytest.param("decl_call_block", 12, True, id="cont_decl_call_block_12"), # Open call ignores width 12. + pytest.param("decl_call_block", 16, True, id="cont_decl_call_block_16"), # Open call ignores width 16. +] + + +@pytest.mark.parametrize("context, width, accepted", _CASES) +def test_measured_continuation_width( + tmp_path: Path, context: str, width: int, accepted: bool, +) -> None: + prefix, operand, line = _CONTEXTS[context] + pine = tmp_path / f"cont_{context}_{width}.pine" + pine.write_text(_HEADER + prefix + " " * width + operand + _TAIL, encoding="utf-8") + result = transpile_json(pine) + assert result["ok"] is accepted, result + if accepted: + assert result["cpp"] + else: + (diagnostic,) = result["diagnostics"] + assert diagnostic["severity"] == "error" + assert diagnostic["code"] == "PF-E0038" + # Two header lines plus the context put the operand on line 4 or 6. + assert (diagnostic["line"], diagnostic["col"]) == (line, width + 1) diff --git a/tests/test_indentation_refusals_extra.py b/tests/test_indentation_refusals_extra.py index 5e8604c2..aa1b22d7 100644 --- a/tests/test_indentation_refusals_extra.py +++ b/tests/test_indentation_refusals_extra.py @@ -30,31 +30,31 @@ pytest.param( "float leaf = close +\n 0.5\n" "float signal = float(leaf) + (0.11)\n", - 3, 21, # The plus is at column 20, so the line ends at column 21. + 4, 9, # Eight spaces place the refused operand at line 4, column 9. id="close-operand", ), pytest.param( "float leaf = open +\n 0.5\n" "float signal = float(leaf) + (0.22)\n", - 3, 20, # The four-letter source puts the line end at column 20. + 4, 9, # The operand's location is independent of the source name. id="open-operand", ), pytest.param( "float leaf = high +\n 0.5\n" "float signal = float(leaf) + (0.33)\n", - 3, 20, # high has the same width as open, ending the line at column 20. + 4, 9, # The eight-space operand still begins at line 4, column 9. id="high-operand", ), pytest.param( "float leaf = low +\n 0.5\n" "float signal = float(leaf) + (0.44)\n", - 3, 19, # The three-letter source puts the line end at column 19. + 4, 9, # The shorter preceding source does not move the operand. id="low-operand", ), pytest.param( "float leaf = hl2 +\n 0.5\n" "float signal = float(leaf) + (0.55)\n", - 3, 19, # hl2 has the same width as low, ending the line at column 19. + 4, 9, # The eight-space operand still begins at line 4, column 9. id="midpoint-operand", ), ], @@ -135,16 +135,16 @@ def test_spaces_before_tab_are_located() -> None: id="mixed-call-whitespace", # Indentation inside an open call stays cosmetic. ), pytest.param( - "choose() =>\n close +\n 0.5\nfloat leaf = choose()\n", - id="block-operand", # The existing callable block wrap still joins its operand. + "float leaf = close +\n\t0.5\n", + id="tab-operand", # Unchanged pin: a tab is outside the space-width rule. ), pytest.param( - "choose() => close +\n 0.5\nfloat leaf = choose()\n", - id="inline-callable-operand", # An inline arrow retains its existing continuation. + "choose() =>\n close +\n\t\t0.5\nfloat leaf = choose()\n", + id="tabbed-block-operand", # Unchanged pin: pending operands keep tab handling. ), pytest.param( - "float leaf = close +\n 0.5\n", - id="other-space-width", # Other existing widths remain outside the added restriction. + "choose() => close +\n\t 0.5\nfloat leaf = choose()\n", + id="tab-spaces-operand", # Unchanged pin: a tab followed by spaces stays accepted. ), pytest.param( "float leaf = close\n + 0.5\n", @@ -154,3 +154,33 @@ def test_spaces_before_tab_are_located() -> None: ) def test_other_indentation_shapes_stay_accepted(body: str) -> None: assert transpile(HEADER + body + "float signal = float(leaf) + (0.11)\n" + TAIL) + + +@pytest.mark.parametrize( + "body,line,col", + [ + pytest.param( + "choose() =>\n close +\n 0.5\nfloat leaf = choose()\n", + 5, 9, # The block operand's eight spaces are structural at line 5. + id="block-operand", + ), + pytest.param( + "choose() => close +\n 0.5\nfloat leaf = choose()\n", + 4, 9, # The inline operand's eight spaces are structural at line 4. + id="inline-callable-operand", + ), + pytest.param( + "float leaf = close +\n 0.5\n", + 4, 13, # Twelve spaces are structural, just like four or eight. + id="other-space-width", + ), + ], +) +def test_multiple_of_four_operands_are_refused(body: str, line: int, col: int) -> None: + source = HEADER + body + "float signal = float(leaf) + (0.11)\n" + TAIL + with pytest.raises(CompileError) as caught: + transpile(source, filename="indentation.pine") + (diagnostic,) = caught.value.diagnostics + assert diagnostic.phase == Phase.LEXER + assert diagnostic.code == "PF-E0038" + assert (diagnostic.location.line, diagnostic.location.col) == (line, col) diff --git a/tests/test_matrix_na_lifecycle.py b/tests/test_matrix_na_lifecycle.py index 01e811b2..8b908932 100644 --- a/tests/test_matrix_na_lifecycle.py +++ b/tests/test_matrix_na_lifecycle.py @@ -693,9 +693,9 @@ def test_callable_persistent_collection_selections_initialize_on_first_reach() - var map missingMap = switch bar_index 2 => map.new() result := (not na(explicitMatrix) ? 1000 : 0) + - (not na(explicitMap) ? 100 : 0) + - (na(missingMatrix) ? 10 : 0) + - (na(missingMap) ? 1 : 0) + (not na(explicitMap) ? 100 : 0) + + (na(missingMatrix) ? 10 : 0) + + (na(missingMap) ? 1 : 0) result value = probe(bar_index >= 1) var int step0 = -99 From b96ca739bd5ed2efbb82f70b7cd10f2c8ef1995c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 04:26:49 +0800 Subject: [PATCH 07/14] lexer: report the continuation refusal at the end of the operator line, as TradingView does; pin every context from the measured tapes Co-Authored-By: Claude Sonnet 5.5 --- pineforge_codegen/lexer.py | 3 +- tests/test_continuation_context.py | 38 ++++----- tests/test_continuation_indent_rules.py | 4 +- tests/test_continuation_widths.py | 102 +++++++++++++++++++++-- tests/test_indentation_refusals_extra.py | 16 ++-- 5 files changed, 123 insertions(+), 40 deletions(-) diff --git a/pineforge_codegen/lexer.py b/pineforge_codegen/lexer.py index bf14b1aa..a25dafcc 100644 --- a/pineforge_codegen/lexer.py +++ b/pineforge_codegen/lexer.py @@ -420,10 +420,11 @@ def _tokenize_line(self) -> None: and not continues_by_indent): # Multiples of four are structural indentation at every block # depth; only a nonmultiple can continue an unfinished expression. + last_token = self.tokens[-1] self._emit_diagnostic( "Line continuation outside parentheses or brackets requires " "space indentation that is not a multiple of four.", - self.line, self.col, self.col + 1, + last_token.line, last_token.end_col, last_token.end_col + 1, ) # Operator-first line continuation: when a line *begins* with a binary diff --git a/tests/test_continuation_context.py b/tests/test_continuation_context.py index e80a4294..f5522710 100644 --- a/tests/test_continuation_context.py +++ b/tests/test_continuation_context.py @@ -10,14 +10,14 @@ _REFUSED_CASES = [ pytest.param( "evaluate() =>\n 1.0 +\n 2.0\nactual = evaluate()\n", - (5, 5), - # Four spaces are structural; the operand starts at line 5, column 5. + (4, 10), + # fn_body_4/error.json: four spaces refuse after the plus at 4:10. id="aligned_function_operand", ), pytest.param( "evaluate() =>\n 1.0 +\n 2.0\nactual = evaluate()\n", - (5, 9), - # Eight spaces are structural; the operand starts at line 5, column 9. + (4, 10), + # fn_body_8/error.json: eight spaces refuse after the plus at 4:10. id="deeper_function_operand", ), pytest.param( @@ -28,8 +28,8 @@ " else\n" " 0.0\n" "actual = evaluate(4.0)\n", - (6, 13), - # Twelve spaces are structural even inside the function's nested block. + (5, 16), + # Hand derivation: nested width 12 refuses; eight spaces + "price +" end at 5:16. id="nested_block_operand", ), pytest.param( @@ -37,8 +37,8 @@ " enabled ? 7.0 :\n" " 2.0\n" "actual = evaluate(false)\n", - (5, 9), - # Eight spaces cannot continue a ternary's false operand. + (4, 20), + # fn_ternary_8/error.json: width 8 refuses; this longer colon line ends at 4:20. id="block_ternary", ), pytest.param( @@ -47,16 +47,16 @@ " price < 10\n" " ready ? 9.0 : 0.0\n" "actual = evaluate(4.0)\n", - (5, 9), - # Eight spaces cannot continue the right operand of and. + (4, 26), + # Hand derivation: width 8 refuses; four spaces + "ready = price > 0 and" end at 4:26. id="block_logical_chain", ), pytest.param( "evaluate(float price) => price +\n" " 3.0\n" "actual = evaluate(4.0)\n", - (4, 5), - # An inline arrow does not exempt an operand at four spaces. + (3, 33), + # fn_arrow_4/error.json: width 4 refuses; this longer inline body ends at 3:33. id="inline_arithmetic_body", ), pytest.param( @@ -65,16 +65,16 @@ " 1.0) +\n" " 3.0\n" "actual = evaluate(4.0)\n", - (6, 9), - # Once the call closes, the plus requires nonmultiple indentation. + (5, 15), + # Hand derivation: after ")" closes the call, width 8 refuses at the plus end, 5:15. id="closed_call_in_block", ), pytest.param( "method doubled(float self) => self *\n" " 2.0\n" "actual = close.doubled()\n", - (4, 9), - # A method's trailing multiplication also requires nonmultiple spaces. + (3, 37), + # Hand derivation: inline method width 8 refuses after its trailing star at 3:37. id="inline_method_operand", ), ] @@ -162,7 +162,5 @@ def test_callable_wrap_does_not_exempt_the_next_script_statement(operand: str) - (diagnostic,) = caught.value.diagnostics assert diagnostic.phase == Phase.LEXER assert diagnostic.code == "PF-E0038" - # The six-space callable wrap is valid; the next operand is on line 7. - assert (diagnostic.location.line, diagnostic.location.col) == ( - 7, len(operand) - len(operand.lstrip()) + 1, - ) + # Hand derivation from cont_top_4/error.json: the next script plus ends at 6:10. + assert (diagnostic.location.line, diagnostic.location.col) == (6, 10) diff --git a/tests/test_continuation_indent_rules.py b/tests/test_continuation_indent_rules.py index 918319ee..6ed7a8a9 100644 --- a/tests/test_continuation_indent_rules.py +++ b/tests/test_continuation_indent_rules.py @@ -572,7 +572,7 @@ if bar_index % 20 == 15 strategy.close("L") """, - (4, 1), # Column zero cannot continue the plus; the operand is at 4:1. + (3, 10), # Hand derivation: width 0 refuses at the plus end, as width 4 does. id="column_zero_operand", ), pytest.param( @@ -585,7 +585,7 @@ if bar_index % 20 == 15 strategy.close("L") """, - (4, 5), # Four spaces cannot continue the plus; the operand is at 4:5. + (3, 10), # cont_top_4/error.json: width 4 refuses; this shorter plus line ends at 3:10. id="four_space_operand", ), pytest.param( diff --git a/tests/test_continuation_widths.py b/tests/test_continuation_widths.py index 40202fbe..ed8b03b4 100644 --- a/tests/test_continuation_widths.py +++ b/tests/test_continuation_widths.py @@ -22,20 +22,21 @@ 'plot(close, "p")\n' ) _CONTEXTS = { - "top": ("float a = close +\n", "open\n", 4), + "top": ("float a = close +\n", "open\n", (3, 18)), "block": ( "float a = 0.0\nif bar_index % 2 == 0\n a := close +\n", - "open\n", 6, + "open\n", (5, 17), ), - "call": ("float a = math.max(close,\n", "open)\n", 4), + "call": ("float a = math.max(close,\n", "open)\n", None), "decl_call_block": ( "float a = 0.0\nif bar_index % 2 == 0\n a := math.max(close,\n", - "open)\n", 6, + "open)\n", None, ), } -# Accepted rows are explicit unchanged pins. Refused rows fail before the -# change: either the lexer accepted them or it located the preceding operator. +# Accepted rows are explicit unchanged pins. Each refused row changes the +# location from the operand to the operator's exclusive end column. +# cont_top_/error.json measures 3:18; cont_block_ measures 5:17. _CASES = [ pytest.param("top", 1, True, id="cont_top_1"), # 1 % 4 = 1: operand joins. pytest.param("top", 2, True, id="cont_top_2"), # 2 % 4 = 2: operand joins. @@ -82,7 +83,7 @@ def test_measured_continuation_width( tmp_path: Path, context: str, width: int, accepted: bool, ) -> None: - prefix, operand, line = _CONTEXTS[context] + prefix, operand, position = _CONTEXTS[context] pine = tmp_path / f"cont_{context}_{width}.pine" pine.write_text(_HEADER + prefix + " " * width + operand + _TAIL, encoding="utf-8") result = transpile_json(pine) @@ -93,5 +94,88 @@ def test_measured_continuation_width( (diagnostic,) = result["diagnostics"] assert diagnostic["severity"] == "error" assert diagnostic["code"] == "PF-E0038" - # Two header lines plus the context put the operand on line 4 or 6. - assert (diagnostic["line"], diagnostic["col"]) == (line, width + 1) + # The trailing plus ends at 3:18 or 5:17 regardless of operand width. + assert (diagnostic["line"], diagnostic["col"]) == position + + +_BODY_CONTEXTS = { + "fn_body": ("f() =>\n 1.0 +\n", "2.0\nplot(f())\n"), + "fn_arrow": ("f(x) => x +\n", "3.0\nplot(f(close))\n"), + "fn_second_stmt": ( + "f(float x) =>\n y = x\n y +\n", "3.0\nplot(f(close))\n", + ), + "method_body": ( + "method m(float self) =>\n self +\n", "3.0\nplot(close.m())\n", + ), + "fn_ternary": ("f(float x) =>\n x > 1 ? 1.0 :\n", "2.0\nplot(f(close))\n"), + "if_block": ( + "float a = 0.0\nif bar_index % 2 == 0\n a := close +\n", + "open\nplot(a)\n", + ), + "for_block": ( + "float a = 0.0\nfor i = 0 to 1\n a += close +\n", + "open\nplot(a)\n", + ), +} +_BODY_TAIL = ( + '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' +) + +# Each row uses _/error.json or tv_trades.csv. +# Refusals fail before the location change; accepted rows are unchanged pins. +_BODY_CASES = [ + pytest.param("fn_body", 4, (4, 10), id="fn_body_4"), # 4 % 4 = 0; plus ends at 4:10. + pytest.param("fn_body", 5, None, id="fn_body_5"), # 5 % 4 = 1; operand joins. + pytest.param("fn_body", 8, (4, 10), id="fn_body_8"), # 8 % 4 = 0; plus ends at 4:10. + pytest.param("fn_body", 9, None, id="fn_body_9"), # 9 % 4 = 1; operand joins. + pytest.param("fn_body", 12, (4, 10), id="fn_body_12"), # 12 % 4 = 0; plus ends at 4:10. + pytest.param("fn_arrow", 4, (3, 12), id="fn_arrow_4"), # 4 % 4 = 0; plus ends at 3:12. + pytest.param("fn_arrow", 5, None, id="fn_arrow_5"), # 5 % 4 = 1; operand joins. + pytest.param("fn_arrow", 8, (3, 12), id="fn_arrow_8"), # 8 % 4 = 0; plus ends at 3:12. + pytest.param("fn_arrow", 9, None, id="fn_arrow_9"), # 9 % 4 = 1; operand joins. + pytest.param("fn_arrow", 12, (3, 12), id="fn_arrow_12"), # 12 % 4 = 0; plus ends at 3:12. + pytest.param("fn_second_stmt", 4, (5, 8), id="fn_second_stmt_4"), # 4 % 4 = 0; plus ends at 5:8. + pytest.param("fn_second_stmt", 5, None, id="fn_second_stmt_5"), # 5 % 4 = 1; operand joins. + pytest.param("fn_second_stmt", 8, (5, 8), id="fn_second_stmt_8"), # 8 % 4 = 0; plus ends at 5:8. + pytest.param("fn_second_stmt", 9, None, id="fn_second_stmt_9"), # 9 % 4 = 1; operand joins. + pytest.param("fn_second_stmt", 12, (5, 8), id="fn_second_stmt_12"), # 12 % 4 = 0; plus ends at 5:8. + pytest.param("method_body", 4, (4, 11), id="method_body_4"), # 4 % 4 = 0; plus ends at 4:11. + pytest.param("method_body", 5, None, id="method_body_5"), # 5 % 4 = 1; operand joins. + pytest.param("method_body", 8, (4, 11), id="method_body_8"), # 8 % 4 = 0; plus ends at 4:11. + pytest.param("method_body", 9, None, id="method_body_9"), # 9 % 4 = 1; operand joins. + pytest.param("method_body", 12, (4, 11), id="method_body_12"), # 12 % 4 = 0; plus ends at 4:11. + pytest.param("fn_ternary", 8, (4, 18), id="fn_ternary_8"), # 8 % 4 = 0; colon ends at 4:18. + pytest.param("fn_ternary", 9, None, id="fn_ternary_9"), # 9 % 4 = 1; false operand joins. + pytest.param("fn_ternary", 12, (4, 18), id="fn_ternary_12"), # 12 % 4 = 0; colon ends at 4:18. + pytest.param("if_block", 4, (5, 17), id="if_block_4"), # 4 % 4 = 0; plus ends at 5:17. + pytest.param("if_block", 5, None, id="if_block_5"), # 5 % 4 = 1; operand joins. + pytest.param("if_block", 8, (5, 17), id="if_block_8"), # 8 % 4 = 0; plus ends at 5:17. + pytest.param("if_block", 9, None, id="if_block_9"), # 9 % 4 = 1; operand joins. + pytest.param("for_block", 4, (5, 17), id="for_block_4"), # 4 % 4 = 0; plus ends at 5:17. + pytest.param("for_block", 5, None, id="for_block_5"), # 5 % 4 = 1; operand joins. + pytest.param("for_block", 8, (5, 17), id="for_block_8"), # 8 % 4 = 0; plus ends at 5:17. + pytest.param("for_block", 9, None, id="for_block_9"), # 9 % 4 = 1; operand joins. +] + + +@pytest.mark.parametrize("context, width, position", _BODY_CASES) +def test_measured_body_continuation_width( + tmp_path: Path, context: str, width: int, position: tuple[int, int] | None, +) -> None: + prefix, operand = _BODY_CONTEXTS[context] + pine = tmp_path / f"{context}_{width}.pine" + pine.write_text( + _HEADER + prefix + " " * width + operand + _BODY_TAIL, encoding="utf-8", + ) + result = transpile_json(pine) + assert result["ok"] is (position is None), result + if position is None: + assert result["cpp"] + else: + (diagnostic,) = result["diagnostics"] + assert diagnostic["severity"] == "error" + assert diagnostic["code"] == "PF-E0038" + assert (diagnostic["line"], diagnostic["col"]) == position diff --git a/tests/test_indentation_refusals_extra.py b/tests/test_indentation_refusals_extra.py index aa1b22d7..03f7d8c7 100644 --- a/tests/test_indentation_refusals_extra.py +++ b/tests/test_indentation_refusals_extra.py @@ -30,31 +30,31 @@ pytest.param( "float leaf = close +\n 0.5\n" "float signal = float(leaf) + (0.11)\n", - 4, 9, # Eight spaces place the refused operand at line 4, column 9. + 3, 21, # cont_top_8/error.json: width 8 refuses; this plus line ends at 3:21. id="close-operand", ), pytest.param( "float leaf = open +\n 0.5\n" "float signal = float(leaf) + (0.22)\n", - 4, 9, # The operand's location is independent of the source name. + 3, 20, # cont_top_8/error.json: width 8 refuses; "open" shortens the end to 3:20. id="open-operand", ), pytest.param( "float leaf = high +\n 0.5\n" "float signal = float(leaf) + (0.33)\n", - 4, 9, # The eight-space operand still begins at line 4, column 9. + 3, 20, # cont_top_8/error.json: width 8 refuses; "high" gives a plus end of 3:20. id="high-operand", ), pytest.param( "float leaf = low +\n 0.5\n" "float signal = float(leaf) + (0.44)\n", - 4, 9, # The shorter preceding source does not move the operand. + 3, 19, # cont_top_8/error.json: width 8 refuses; "low" gives a plus end of 3:19. id="low-operand", ), pytest.param( "float leaf = hl2 +\n 0.5\n" "float signal = float(leaf) + (0.55)\n", - 4, 9, # The eight-space operand still begins at line 4, column 9. + 3, 19, # cont_top_8/error.json: width 8 refuses; "hl2" gives a plus end of 3:19. id="midpoint-operand", ), ], @@ -161,17 +161,17 @@ def test_other_indentation_shapes_stay_accepted(body: str) -> None: [ pytest.param( "choose() =>\n close +\n 0.5\nfloat leaf = choose()\n", - 5, 9, # The block operand's eight spaces are structural at line 5. + 4, 12, # fn_body_8/error.json: width 8 refuses; " close +" ends at 4:12. id="block-operand", ), pytest.param( "choose() => close +\n 0.5\nfloat leaf = choose()\n", - 4, 9, # The inline operand's eight spaces are structural at line 4. + 3, 20, # fn_arrow_8/error.json: width 8 refuses; this inline plus ends at 3:20. id="inline-callable-operand", ), pytest.param( "float leaf = close +\n 0.5\n", - 4, 13, # Twelve spaces are structural, just like four or eight. + 3, 21, # cont_top_12/error.json: width 12 refuses; this plus line ends at 3:21. id="other-space-width", ), ], From 307bc16f058a8a3919543f9f2adb60578cfcfd3d Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 04:41:23 +0800 Subject: [PATCH 08/14] lexer: a lexeme that error recovery consumes without a token does not leave the previous token pending as a line-end operator Found by the lab: the generalised continuation refusal fired on a line whose last lexeme (a non-ASCII character) emitted no token, because the line-end check read the last token of an earlier line. Co-Authored-By: Claude Sonnet 5.5 --- pineforge_codegen/lexer.py | 10 +++- tests/test_continuation_recovery.py | 92 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/test_continuation_recovery.py diff --git a/pineforge_codegen/lexer.py b/pineforge_codegen/lexer.py index a25dafcc..aba38033 100644 --- a/pineforge_codegen/lexer.py +++ b/pineforge_codegen/lexer.py @@ -354,6 +354,7 @@ def _tokenize_line(self) -> None: while not self._at_end() and self.source[self.pos] in (" ", "\t"): self._advance() emitted_in_parens = False + last_token = None while not self._at_end() and self.source[self.pos] != "\n": self._skip_whitespace_inline() if self._at_end() or self.source[self.pos] == "\n": @@ -362,14 +363,15 @@ def _tokenize_line(self) -> None: self._skip_comment() break emitted_in_parens = True + token_count = len(self.tokens) self._read_token() + last_token = self.tokens[-1] if len(self.tokens) > token_count else None if not self._at_end() and self.source[self.pos] == "\n": self._advance() # If parens closed on this line, end the statement UNLESS the line # ends with a continuation token (e.g. trailing operator), in which # case the next line continues the same logical line. if self.paren_depth == 0 and emitted_in_parens: - last_token = self.tokens[-1] if self.tokens else None if last_token and last_token.type in self.CONTINUATION_TOKENS: self._in_continuation = True else: @@ -475,6 +477,7 @@ def _tokenize_line(self) -> None: # Tokens on this line first_token = len(self.tokens) emitted_something = False + last_token = None while not self._at_end() and self.source[self.pos] != "\n": self._skip_whitespace_inline() if self._at_end() or self.source[self.pos] == "\n": @@ -483,7 +486,11 @@ def _tokenize_line(self) -> None: self._skip_comment() break emitted_something = True + token_count = len(self.tokens) self._read_token() + # Error recovery can consume a lexeme without emitting a token. + # Only the final lexeme can leave an operand pending at line end. + last_token = self.tokens[-1] if len(self.tokens) > token_count else None if indent_join and len(self.tokens) > first_token: self.tokens[first_token].starts_indent_continuation = True @@ -493,7 +500,6 @@ def _tokenize_line(self) -> None: # Check if this line ends with a continuation token if emitted_something and self.paren_depth == 0: - last_token = self.tokens[-1] if self.tokens else None if last_token and last_token.type in self.CONTINUATION_TOKENS: # Next line is a continuation — don't emit NEWLINE self._in_continuation = True diff --git a/tests/test_continuation_recovery.py b/tests/test_continuation_recovery.py new file mode 100644 index 00000000..d0730df6 --- /dev/null +++ b/tests/test_continuation_recovery.py @@ -0,0 +1,92 @@ +"""Continuation requires an operator at the actual end of the source line.""" + +import pytest + +from pineforge_codegen.errors import CompileError, Level, Phase +from pineforge_codegen.lexer import Lexer, TokenType + + +@pytest.mark.parametrize( + "header", + [ + "f(x) =>", # Unchanged pin: the arrow opens a four-space callable body. + "method f(float self) =>", # Unchanged pin: a method arrow also opens a block. + "if true", # Unchanged pin: the completed condition opens a block. + "else", # Unchanged pin: else is structural, with no pending operand. + "for i = 0 to 1", # Unchanged pin: the loop bound completes the header. + "for x in values", # Unchanged pin: the collection completes the header. + "while false", # Unchanged pin: the completed condition opens a block. + "switch", # Unchanged pin: selector-free switch opens its case block. + "switch x", # Unchanged pin: the selector completes the switch header. + "type Cell", # Unchanged pin: the type name completes the header. + "enum Size", # Unchanged pin: the enum name completes the header. + ], +) +def test_structural_header_keeps_its_block_boundary(header: str) -> None: + tokens = Lexer(header + "\n x\n").tokenize() + # A completed header emits NEWLINE, then INDENT before the body's x. + body_index = next(i for i, token in enumerate(tokens) + if token.type == TokenType.IDENT and token.line == 2) + assert [token.type for token in tokens[body_index - 2:body_index]] == [ + TokenType.NEWLINE, TokenType.INDENT, + ] + + +def test_non_ascii_callable_body_keeps_only_character_diagnostics() -> None: + source = ("//@version=6\n" + "\u03b1(float \u03b2) =>\n" + " var float \u03b3 = \u03b2\n" + " \u03b3\n") + with pytest.raises(CompileError) as caught: + Lexer(source).tokenize() + # Five rejected identifier characters; the consumed beta after '=' is no operator. + assert [(d.code, d.message, d.location.line, d.location.col, d.location.end_col) + for d in caught.value.diagnostics] == [ + ("PF-E0044", "no viable alternative at character '\u03b1'", 2, 1, 2), + ("PF-E0044", "no viable alternative at character '\u03b2'", 2, 9, 10), + ("PF-E0044", "no viable alternative at character '\u03b3'", 3, 15, 16), + ("PF-E0044", "no viable alternative at character '\u03b2'", 3, 19, 20), + ("PF-E0044", "no viable alternative at character '\u03b3'", 4, 5, 6), + ] + + +@pytest.mark.parametrize( + "prefix, line, col", + [ + ("value = ", 2, 9), # '=' ends at 2:8; the invalid lexeme follows it at 2:9. + ("value = (\n 1) + ", 3, 10), # The parenthesis-closing scan's '+' ends at 3:9. + ("value = 1 +\n ", 3, 2), # An error-only wrapped line must not reuse 2:12's '+'. + ], +) +@pytest.mark.parametrize( + "suffix, message", + [ + ("\u03b1", "no viable alternative at character '\u03b1'"), # Identifier recovery emits no token. + ("\u0662", "no viable alternative at character '\u0662'"), # Non-ASCII digit recovery emits no token. + ("$", "Unexpected character: '$'"), # Unknown-character recovery emits no token. + ("#", "Invalid color literal: expected hex digits after '#'"), # An empty color emits no token. + ], +) +def test_consumed_error_is_not_a_trailing_operator( + prefix: str, line: int, col: int, suffix: str, message: str, +) -> None: + with pytest.raises(CompileError) as caught: + Lexer("//@version=6\n" + prefix + suffix + "\n next = 1\n").tokenize() + # The sole invalid character is consumed at (line, col), after the earlier operator. + (diagnostic,) = caught.value.diagnostics + assert diagnostic.message == message + assert diagnostic.phase == Phase.LEXER + assert diagnostic.level == Level.ERROR + assert (diagnostic.location.line, diagnostic.location.col, + diagnostic.location.end_col) == (line, col, col + 1) + + +def test_error_before_a_real_trailing_operator_preserves_the_refusal() -> None: + with pytest.raises(CompileError) as caught: + Lexer("//@version=6\nvalue = \u03b1 +\n 2\n").tokenize() + # Unchanged pin: alpha is at 2:9, but '+' really ends this line at exclusive 2:12. + assert [(d.code, d.location.line, d.location.col, d.location.end_col) + for d in caught.value.diagnostics] == [ + ("PF-E0044", 2, 9, 10), + ("PF-E0038", 2, 12, 13), + ] From 52f05f9a2bf60935dbd7e1825ca0214f2b0d37c9 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 22:39:11 +0800 Subject: [PATCH 09/14] Keep the fraction of a division in a loop start: separate the float-step check from the counter's storage In Pine, `/` always produces a float value, so a loop such as `for i = n / 2 to n / 2 + 2` visits 2.5, 3.5, 4.5 when n is 5. The loop-counter query typed the start by joining its operands, so `n / 2` with int operands was an int and the generated loop declared an int counter and truncated the start: the loop visited 2, 3, 4. TradingView keeps the fraction. TradingView's compile check is a separate matter: it still treats a division of two ints as an int when it decides whether a float step is compatible with the counter (`for i = 4 / 2 to 6 by 0.5` is refused for a float step, in the direct, named and helper forms). So the counter query now answers two questions. The check type is unchanged, so the float-step refusal keeps refusing exactly what TradingView refuses. A new value type treats a division as a float value and selects the float counter, so the fraction of a division start is kept. Both answers come from one recursion, the shared type inference is untouched, and a division only in the loop end does not change the counter. Tests cover the refused float-step forms, the double counter for division starts with the default and an explicit int step, native runs whose sums are 10.5 for the input form, 10.5 and 12 for the series form and 10.5 for a constant start, and the unchanged int controls. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 12 ++- CHANGELOG.md | 12 ++- CLAUDE.md | 12 ++- pineforge_codegen/loop_types.py | 38 ++++++-- tests/_for_float_step.py | 44 ++++++++- tests/test_e2e_for_counter_headers.py | 39 +++++++- tests/test_for_counter_type_sources.py | 54 ++++++++++- tests/test_for_float_step.py | 122 ++++++++++++++++++++++++- 8 files changed, 305 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2258674..7b74742f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,11 +398,13 @@ loop values and `break` / `continue`. A non-na v6 step <= 0 after conversion to the counter's type stops the run with "'step' in loop must be greater than zero". The float-counter route is v6 only: positive float evidence in the start, or an unknown start with positive float evidence in the step, selects a float -counter. A start with positive int evidence keeps an int counter and its existing -end/step narrowing. A v6 loop whose start has positive int evidence refuses a -float-typed step at the step expression (`PF-E2079`), with the hint "Use an int -step or a float counter." Without float evidence, counters keep their existing -int route. +counter. A division in a loop start keeps its fractional value (the counter is +a float), while the float-step refusal follows TradingView's check type (a +division of two ints is int). A start whose value has positive int evidence +keeps an int counter and its existing end/step narrowing. A v6 loop whose start +has positive int check evidence refuses a float-typed step at the step +expression (`PF-E2079`), with the hint "Use an int step or a float counter." +Without float evidence, counters keep their existing int route. v5 library bodies keep their int counters and existing step handling. The counter now wins over an outer same-named variable. An affine chart loop proved endless (its end is affine in its own counter) is stopped with the loop-time error when diff --git a/CHANGELOG.md b/CHANGELOG.md index da3a6f97..18a1f3e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ supported as exact pairs; on the 0.x line they are independent. See the - Pine v6 calls refuse a positional argument after a named argument with `PF-E2074` at that positional expression. The check also covers strategy declarations, unused functions and v6 library bodies before lowering. -- A v6 counted loop whose start has positive int evidence now refuses a +- A v6 counted loop whose start has positive int check evidence now refuses a float-typed step during translation with `PF-E2079` at the step expression and the hint "Use an int step or a float counter." An unknown start with a positively float step still selects a float counter. @@ -49,11 +49,15 @@ supported as exact pairs; on the 0.x line they are independent. See the relink affected strategies. - A v6 counted loop uses a float counter when its start has positive float evidence, or when its start is unknown and its step has positive float - evidence, including inside a `request.security` helper. A start with positive - int evidence keeps an int counter. Without float evidence, the existing int - route is unchanged; v5 library loops keep their existing int-counter route. + evidence, including inside a `request.security` helper. A start whose value + has positive int evidence keeps an int counter. Without float evidence, the + existing int route is unchanged; v5 library loops keep their existing + int-counter route. Counter-family inference follows declaration sources in a dedicated query, independently of shared expression and storage inference. + A division in a loop start keeps its fractional value (the counter is a + float), while the float-step refusal follows TradingView's check type (a + division of two ints is int). - Float-counter loops accumulate the counter and continue within an absolute allowance of the end: `x <= end + kFloatStepTolerance` ascending, with the mirrored comparison descending. `0.0 to 0.3 by 0.1` counts four iterations. diff --git a/CLAUDE.md b/CLAUDE.md index 8d88db7e..b15e37d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -398,11 +398,13 @@ loop values and `break` / `continue`. A non-na v6 step <= 0 after conversion to the counter's type stops the run with "'step' in loop must be greater than zero". The float-counter route is v6 only: positive float evidence in the start, or an unknown start with positive float evidence in the step, selects a float -counter. A start with positive int evidence keeps an int counter and its existing -end/step narrowing. A v6 loop whose start has positive int evidence refuses a -float-typed step at the step expression (`PF-E2079`), with the hint "Use an int -step or a float counter." Without float evidence, counters keep their existing -int route. +counter. A division in a loop start keeps its fractional value (the counter is +a float), while the float-step refusal follows TradingView's check type (a +division of two ints is int). A start whose value has positive int evidence +keeps an int counter and its existing end/step narrowing. A v6 loop whose start +has positive int check evidence refuses a float-typed step at the step +expression (`PF-E2079`), with the hint "Use an int step or a float counter." +Without float evidence, counters keep their existing int route. v5 library bodies keep their int counters and existing step handling. The counter now wins over an outer same-named variable. An affine chart loop proved endless (its end is affine in its own counter) is stopped with the loop-time error when diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 10e5e3d8..6b70671a 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -101,6 +101,7 @@ def __init__(self, sources, bindings, scopes): # All declaration routes, including Identifier -> VarDecl, share this # query-local memo. A provisional unknown also breaks source cycles. self.declaration_specs = {} + self._value_query = None def declaration(self, node): key = id(node) @@ -121,12 +122,17 @@ def declaration(self, node): self.declaration_specs[key] = spec return self.declaration_specs[key] + def value_spec(self, node): + if self._value_query is None: + self._value_query = _CounterValueQuery(self) + return self._value_query.infer(node) + def counter_spec(self, start, step, *, counter_name=None, step_location=None): - spec = self.infer(start) + check_spec = self.infer(start) step_spec = self.infer(step) - # Refusal and counter selection use these same source facts. An int - # fallback for an unknown start is not positive int evidence. - if spec == _INT and step_spec == _FLOAT and counter_name is not None: + # TradingView checks int/int division as int even though its value + # can be fractional. Keep that check separate from counter storage. + if check_spec == _INT and step_spec == _FLOAT and counter_name is not None: step_type, counter_type = "float", "int" raise CompileError([Diagnostic( level=Level.ERROR, @@ -138,8 +144,10 @@ def counter_spec(self, start, step, *, counter_name=None, step_location=None): ), hint="Use an int step or a float counter.", )]) - # Unknown starts with a positively float step keep the float route. - if spec == _FLOAT or (spec in (None, _NA) and step_spec == _FLOAT): + value_spec = self.value_spec(start) + # Unknown starts with a positively float step keep the float route; + # an int fallback is still not positive int evidence. + if value_spec == _FLOAT or (check_spec in (None, _NA) and step_spec == _FLOAT): return _FLOAT return _INT @@ -285,6 +293,24 @@ def array_result(name, spec): return None +class _CounterValueQuery(_CounterQuery): + """Reuse the check recursion with fractional division values and its own memo.""" + + def __init__(self, check_query): + super().__init__(check_query.sources, check_query.bindings, check_query.scopes) + self.check_query = check_query + + def infer(self, node): + if isinstance(node, BinOp) and node.op == "/": + return _FLOAT + return super().infer(node) + + def counter_spec(self, start, step, **kwargs): + # A referenced loop counter must use the same check/storage decision + # as analysis, including the original check type of its step. + return self.check_query.counter_spec(start, step, **kwargs) + + def for_counter_type( start, sources, bindings, scopes, *, step=None, pine_v5=False, counter_types=None, counter_name=None, step_location=None, diff --git a/tests/_for_float_step.py b/tests/_for_float_step.py index 5090cf60..8f061b63 100644 --- a/tests/_for_float_step.py +++ b/tests/_for_float_step.py @@ -1,4 +1,4 @@ -"""Diagnostic assertions shared by the loop source inventories.""" +"""Loop-step sources and diagnostic assertions shared by the inventories.""" import re @@ -8,6 +8,48 @@ from pineforge_codegen.errors import CompileError, Level, Phase +DIVISION_STARTS = { + "literal-division": ("", "4 / 2"), + "named-division": ("s = 4 / 2\n", "s"), + "helper-division": ("startValue() => 4 / 2\n", "startValue()"), + "series-division": ("", "bar_index / 2"), +} + +# prefix, start, end, float-start twin, twin end. The default-step sums are +# 10.5 for odd n and 12 for even n; the integral constant sums to 20. +DIVISION_VALUE_STARTS = { + "fractional-input": ( + "n = input.int(5)\n", "n / 2", "n / 2 + 2", "2.5", "4.5", + ), + "fractional-series": ( + "n = 5 + bar_index % 2\n", "n / 2", "n / 2 + 2", + "(bar_index % 2 == 0 ? 2.5 : 3.0)", + "(bar_index % 2 == 0 ? 4.5 : 5.0)", + ), + "fractional-constant": ("", "5 / 2", "4.5", "2.5", "4.5"), + "integral-constant": ("", "4 / 2", "6", "2.0", "6"), +} +DIVISION_VALUE_SUMS = { + "fractional-input": (10.5, 10.5), + "fractional-series": (10.5, 12), + "fractional-constant": (10.5, 10.5), + "integral-constant": (20, 20), +} + + +def loop_step_source( + start: str, *, prefix: str = "", end: str = "6", step: str | None = "0.5", +) -> str: + by = "" if step is None else f" by {step}" + return ( + '//@version=6\nstrategy("Loop step types", currency=currency.NONE)\n' + + prefix + "float acc = 0.0\n" + f"for i = {start} to {end}{by}\n" + " acc += i\n" + "plot(acc)\n" + ) + + def assert_float_step_refused( source: str, *, counter: str = "i", location: tuple[int, int, int] | None = None, ) -> None: diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index fdec2bfc..163c3e47 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -17,8 +17,11 @@ from tests import _compile as compile_env from pineforge_codegen import transpile -from tests._e2e import Build, chart_feed_head, execute_all, ok, skip_unless_e2e_env +from tests._e2e import ( + Build, assert_same_runs, chart_feed_head, execute_all, ok, skip_unless_e2e_env, +) from tests._for_counter_header_cases import STOP_SOURCES +from tests._for_float_step import DIVISION_VALUE_STARTS, DIVISION_VALUE_SUMS FIXTURES = Path(__file__).parent / "fixtures" / "for_counter_headers" @@ -334,6 +337,40 @@ def test_counter_binding_compositions(runs, name): _assert_quantities(outcome, tag, expected) +@pytest.fixture(scope="module") +def division_runs(tmp_path_factory): + engine = skip_unless_e2e_env() + base = tmp_path_factory.mktemp("for_division_values") + feed = chart_feed_head(engine, base, 32) + builds = {} + for name, (prefix, start, end, twin, twin_end) in DIVISION_VALUE_STARTS.items(): + for suffix, first, last in (("", start, end), ("_twin", twin, twin_end)): + source = _source( + prefix + "float acc = 0.0\n" + f"for i = {first} to {last}\n" + " acc += i\n" + ) + "// @pf-trace acc=acc\n" + builds[name + suffix] = Build(source, trace=True) + return execute_all(engine, feed, base, builds) + + +@pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) +def test_division_start_default_step_values_and_trades(division_runs, name): + outcome = ok(division_runs, name) + assert_same_runs(outcome, ok(division_runs, name + "_twin")) + records = outcome.traces["default"] + assert len(records) == 32 + sums = DIVISION_VALUE_SUMS[name] + for bar, record in enumerate(records): + assert record["name"] == "acc" + assert record["bar_index"] == bar + assert record["value"] == sums[bar % 2], record + rows = csv.DictReader(outcome.trades["default"].decode("utf-8-sig").splitlines()) + entries = [row for row in rows if row["Type"].startswith("Entry")] + expected_quantities = [sums[bar % 2] for bar in (10, 17, 24)] + assert [float(row["Qty"]) for row in entries] == expected_quantities + + STOP_DRIVER = r''' #include #include diff --git a/tests/test_for_counter_type_sources.py b/tests/test_for_counter_type_sources.py index 938f6ba5..2b86a841 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -13,11 +13,14 @@ from pineforge_codegen.ast_nodes import ForStmt from pineforge_codegen.lexer import Lexer from pineforge_codegen.limits import iter_ast_nodes +from pineforge_codegen.loop_types import LoopTypeSources, _CounterQuery from pineforge_codegen.parser import Parser -from pineforge_codegen.symbols import PineType +from pineforge_codegen.symbols import PineType, TypeSpec from tests import _compile as compile_env from tests._for_counter_header_cases import INT_START_SOURCES -from tests._for_float_step import assert_float_step_refused +from tests._for_float_step import ( + DIVISION_STARTS, DIVISION_VALUE_STARTS, assert_float_step_refused, loop_step_source, +) from tests._for_counter_type_source_cases import ( ARRAY_TYPES, BASELINE, BASELINE_SOURCES, FLOAT_COUNTER_SOURCES, INT_COUNTER_SOURCES, MAIN_COMMIT, @@ -145,6 +148,27 @@ def test_positive_int_parameter_start_refuses_a_float_step(): assert_float_step_refused(pine, counter="walkIndex", location=(5, 37, 41)) +@pytest.mark.parametrize("prefix,start", [ + *(pytest.param(prefix, start, id=name) + for name, (prefix, start) in sorted(DIVISION_STARTS.items())), + *(pytest.param(prefix, start, id=name) + for name, (prefix, start, _end, _twin, _twin_end) + in sorted(DIVISION_VALUE_STARTS.items())), +]) +def test_division_start_has_distinct_check_and_value_specs(prefix, start): + pine = loop_step_source(start, prefix=prefix, step=None) + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + ctx = Analyzer(ast).analyze() + loop = next(node for node, _ in iter_ast_nodes(ast) if isinstance(node, ForStmt)) + query = _CounterQuery( + LoopTypeSources(ast), + ctx.identifier_decl_bindings, ctx.identifier_binding_scopes, + ) + assert query.infer(loop.start) == TypeSpec.primitive("int") + assert query.value_spec(loop.start) == TypeSpec.primitive("float") + assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT + + @pytest.mark.parametrize("context", ["chart", "security"]) @pytest.mark.parametrize("counter_bound", [False, True]) @pytest.mark.parametrize("prefix,setup,start,step,family", [ @@ -164,6 +188,32 @@ def test_positive_int_parameter_start_refuses_a_float_step(): ("", "", "(true ? 0 : na)", "0.25", "refused"), ("a = array.from(0.0, 1.0)\n", "", "array.size(a)", "0.25", "refused"), ("a = array.from(0.0, 1.0)\n", "", "a.size()", "0.25", "refused"), + pytest.param("", "", "4 / 2", "0.5", "refused", id="division-start"), + pytest.param("", "begin = 4 / 2\n", "begin", "0.5", "refused", + id="named-division-start"), + pytest.param("startValue() => 4 / 2\n", "", "startValue()", "0.5", "refused", + id="helper-division-start"), + pytest.param("", "", "bar_index / 2", "0.5", "refused", id="series-division-start"), + pytest.param("", "", "4 / 2", "1", "double", id="division-int-step"), + pytest.param("", "begin = 4 / 2\n", "begin", "1", "double", + id="named-division-int-step"), + pytest.param("startValue() => 4 / 2\n", "", "startValue()", "1", "double", + id="helper-division-int-step"), + pytest.param("n = input.int(5)\n", "", "n / 2", "1", "double", + id="input-division-int-step"), + pytest.param("", "n = 5 + bar_index % 2\n", "n / 2", "1", "double", + id="series-division-int-step"), + # Step checking retains the operand join, even when the value query for + # a start would see the same division as positively float. + pytest.param("", "", "0", "4 / 2", "int", id="int-division-step"), + pytest.param("", "", "first", "4 / 2", "int", id="unknown-start-int-division-step"), + pytest.param("", "", "0", "4 / 2.0", "refused", id="float-division-step"), + # An int step makes these rows depend on division's result family: + # an unknown operand must not leave the start on the int fallback. + pytest.param("", "", "first / 2", "1", "double", id="division-unknown-left"), + pytest.param("", "", "2 / first", "1", "double", id="division-unknown-right"), + pytest.param("", "", "na / 2", "1", "double", id="division-na-left"), + pytest.param("", "", "2 / na", "1", "double", id="division-na-right"), ]) def test_step_evidence_only_promotes_an_unknown_start( context, counter_bound, prefix, setup, start, step, family, diff --git a/tests/test_for_float_step.py b/tests/test_for_float_step.py index 73aaf33a..5370e659 100644 --- a/tests/test_for_float_step.py +++ b/tests/test_for_float_step.py @@ -1,12 +1,19 @@ """TradingView loop-step type boundaries and integer-loop controls.""" +import hashlib import re import pytest from pineforge_codegen import transpile, transpile_full from pineforge_codegen.errors import CompileError -from tests._for_float_step import assert_float_step_refused +from tests import _compile as compile_env +from tests._e2e import reference_codegen, transpile_json +from tests._for_counter_header_cases import MAIN_COMMIT as LOOP_CPP_REFERENCE +from tests._for_float_step import ( + DIVISION_STARTS, DIVISION_VALUE_STARTS, DIVISION_VALUE_SUMS, + assert_float_step_refused, loop_step_source, +) # TradingView refuses this form. COUNTER_DEPENDENT_STEP = """//@version=6 @@ -231,6 +238,7 @@ def test_integer_step_expressions_still_translate(step: str) -> None: @pytest.mark.parametrize("prefix,start,double_counter", [ ("", "0.0", True), ("", "float(0)", True), + ("", "4 / 2.0", True), ("", "math.max(0, 0.0)", True), ("", "math.round(0, 2)", True), ("", "(true ? na : 0.0)", True), @@ -261,10 +269,116 @@ def test_float_step_refusal_uses_the_same_counter_family_as_emission( assert_float_step_refused(source) -@pytest.mark.parametrize("argument", ["0", "0.0"]) +@pytest.mark.parametrize("name", sorted(DIVISION_STARTS)) +def test_int_division_start_still_refuses_a_float_step(name): + prefix, start = DIVISION_STARTS[name] + assert_float_step_refused(loop_step_source(start, prefix=prefix)) + + +@pytest.mark.parametrize("name", sorted(DIVISION_STARTS)) +def test_division_start_emits_a_float_counter_with_default_step(name): + prefix, start = DIVISION_STARTS[name] + cpp = transpile(loop_step_source(start, prefix=prefix, step=None)) + assert re.search(r"\bfor \(double i = ", cpp), cpp + assert not re.search(r"\bfor \(int i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp + assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 + assert cpp.count("::kFloatStepTolerance") == 2 + + +@pytest.mark.parametrize("start,end,step", [ + pytest.param("0", "4", "1.0", id="int-literal"), + pytest.param("1 + 1", "6", "0.5", id="int-addition"), + pytest.param("4 - 2", "6", "0.5", id="int-subtraction"), + pytest.param("2 * 1", "6", "0.5", id="int-multiplication"), + pytest.param("7 % 4", "6", "0.5", id="int-remainder"), +]) +def test_integer_start_arithmetic_still_refuses_a_float_step(start, end, step): + assert_float_step_refused(loop_step_source(start, end=end, step=step)) + + +@pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) +def test_division_start_without_step_matches_a_float_start(name): + prefix, start, end, twin, twin_end = DIVISION_VALUE_STARTS[name] + sources = [ + loop_step_source(start, prefix=prefix, end=end, step=None), + loop_step_source(twin, prefix=prefix, end=twin_end, step=None), + ] + normalized = [] + for source in sources: + cpp = transpile(source) + # Compare all C++ apart from the authored bound expressions that + # differ. Header types, step, body and tolerance must match. + slots = [("start", 1)] + if end != twin_end: + slots.append(("end", 2)) + for slot, count_expected in slots: + cpp, count = re.subn( + rf"(\b(?:double )?_for_{slot}_\d+ = )[^;\n]+(?=;|\) \{{)", + rf"\1<{slot}>", cpp, + ) + assert count == count_expected, cpp + normalized.append(cpp) + assert normalized[0] == normalized[1] + + +@pytest.mark.parametrize("start,end", [ + pytest.param("2", "6", id="int-literal"), + pytest.param("1 + 1", "6", id="int-addition"), + pytest.param("2 * 1", "6", id="int-multiplication"), + pytest.param("7 % 4", "6", id="int-remainder"), + pytest.param("0", "7 / 2", id="division-only-in-end"), +]) +def test_integer_counter_keeps_frozen_cpp(start, end, tmp_path): + source = loop_step_source(start, end=end, step=None) + cpp = transpile(source) + assert re.search(r"\bfor \(int i = ", cpp), cpp + assert "kFloatStepTolerance" not in cpp + # Use the loop inventory's immutable reference, without replacing any + # stored hash or excluding any emitted text from the digest. + reference = reference_codegen(LOOP_CPP_REFERENCE) + assert reference is not None, "Required loop C++ reference unavailable; restore git history." + pine = tmp_path / "strategy.pine" + pine.write_text(source, encoding="utf-8") + before = transpile_json(pine, root=reference) + assert before["ok"], before["diagnostics"] + assert hashlib.sha256(cpp.encode("utf-8")).hexdigest() == hashlib.sha256( + before["cpp"].encode("utf-8"), + ).hexdigest() + + +@pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) +def test_division_start_preserves_counter_values(name): + prefix, start, end, _twin, _twin_end = DIVISION_VALUE_STARTS[name] + expected = ",".join(str(value) for value in DIVISION_VALUE_SUMS[name]) + driver = r''' +#include +#include +int main() { + Bar bars[] = { + {10.0, 10.0, 10.0, 10.0, 1.0, 60000}, + {10.0, 10.0, 10.0, 10.0, 1.0, 120000}, + }; + GeneratedStrategy first_bar, second_bar; + first_bar.run(bars, 1, "1", "1"); + second_bar.run(bars, 2, "1", "1"); + assert(first_bar.last_error().empty()); + assert(second_bar.last_error().empty()); + std::cout << first_bar.acc << "," << second_bar.acc; +} +''' + assert compile_env.run_emitted_tu( + transpile(loop_step_source(start, prefix=prefix, end=end, step=None)), + driver, opt="-O2", label=name, + ) == expected + + +@pytest.mark.parametrize("argument", ["0", "0.0", "4 / 2"]) def test_untyped_parameter_start_keeps_documented_call_argument_gap(argument): - # TradingView refuses the int call and accepts the float call. Shared - # parameter inference leaves this start unknown, so both use double here. + # TradingView refuses the measured int call and accepts the float call. + # Shared inference leaves the parameter unknown even for a division + # argument, so each call keeps the float-step route here. source = _script( "measure(first) =>\n" " float acc = 0.0\n" From 687177f68799ffc7c4864b44bee472c181ae681b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 22:56:24 +0800 Subject: [PATCH 10/14] Keep the int counter for a division that is provably an integer; repair three loop-type rows A division that keeps a fraction selects the float counter, but a constant division with an exact integer result (`4 / 2`, `8 / 4`, `-4 / 2`, or a name bound once to such a constant) is an integer value, so its loop keeps the int counter and its emitted C++ does not change. The proof uses only integer literals, unary plus and minus, parentheses, `+ - *`, exact `/` and `%`, and names bound once to such constants; a division by zero, an input, a series, a call, a float literal or a name that is written anywhere else is not proved and keeps the fraction. Two rows built a request.security helper loop that holds a user function call, which is not a legal source; they now use legal forms. The comparison with the float twin no longer relies on a text substitution over the whole C++: it compares the complete output against the frozen reference for each form. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 7 +- CHANGELOG.md | 6 +- CLAUDE.md | 7 +- pineforge_codegen/loop_types.py | 53 +++++++++++- tests/_for_float_step.py | 25 +++--- tests/test_e2e_for_counter_headers.py | 2 +- tests/test_for_counter_type_sources.py | 73 ++++++++++++++--- tests/test_for_float_step.py | 109 +++++++++++++++++++------ 8 files changed, 221 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7b74742f..56c0dcec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,9 +398,10 @@ loop values and `break` / `continue`. A non-na v6 step <= 0 after conversion to the counter's type stops the run with "'step' in loop must be greater than zero". The float-counter route is v6 only: positive float evidence in the start, or an unknown start with positive float evidence in the step, selects a float -counter. A division in a loop start keeps its fractional value (the counter is -a float), while the float-step refusal follows TradingView's check type (a -division of two ints is int). A start whose value has positive int evidence +counter. A division in a loop start keeps its fraction unless it is a constant +division with an exact integer result, which keeps the int route. The float-step +refusal follows TradingView's check type (a division of two ints is int). +A start whose value has positive int evidence keeps an int counter and its existing end/step narrowing. A v6 loop whose start has positive int check evidence refuses a float-typed step at the step expression (`PF-E2079`), with the hint "Use an int step or a float counter." diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a1f3e6..8bf89e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,9 +55,9 @@ supported as exact pairs; on the 0.x line they are independent. See the int-counter route. Counter-family inference follows declaration sources in a dedicated query, independently of shared expression and storage inference. - A division in a loop start keeps its fractional value (the counter is a - float), while the float-step refusal follows TradingView's check type (a - division of two ints is int). + A division in a loop start keeps its fraction unless it is a constant + division with an exact integer result, which keeps the int route. The float-step + refusal follows TradingView's check type (a division of two ints is int). - Float-counter loops accumulate the counter and continue within an absolute allowance of the end: `x <= end + kFloatStepTolerance` ascending, with the mirrored comparison descending. `0.0 to 0.3 by 0.1` counts four iterations. diff --git a/CLAUDE.md b/CLAUDE.md index b15e37d3..df5f99fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -398,9 +398,10 @@ loop values and `break` / `continue`. A non-na v6 step <= 0 after conversion to the counter's type stops the run with "'step' in loop must be greater than zero". The float-counter route is v6 only: positive float evidence in the start, or an unknown start with positive float evidence in the step, selects a float -counter. A division in a loop start keeps its fractional value (the counter is -a float), while the float-step refusal follows TradingView's check type (a -division of two ints is int). A start whose value has positive int evidence +counter. A division in a loop start keeps its fraction unless it is a constant +division with an exact integer result, which keeps the int route. The float-step +refusal follows TradingView's check type (a division of two ints is int). +A start whose value has positive int evidence keeps an int counter and its existing end/step narrowing. A v6 loop whose start has positive int check evidence refuses a float-typed step at the step expression (`PF-E2079`), with the hint "Use an int step or a float counter." diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 6b70671a..10e24a9a 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -3,7 +3,7 @@ from __future__ import annotations from .ast_nodes import ( - BinOp, ExprStmt, ForStmt, FuncCall, FuncDef, Identifier, IfStmt, + Assignment, BinOp, ExprStmt, ForStmt, FuncCall, FuncDef, Identifier, IfStmt, MemberAccess, NaLiteral, NumberLiteral, Subscript, SwitchStmt, Ternary, TupleAssign, TupleLiteral, UnaryOp, VarDecl, ) @@ -80,7 +80,11 @@ def __init__(self, ast): self.declarations = {} self.functions = {} self.parameter_hints = {} + self.reassigned_names = set() for node, _depth in iter_ast_nodes(ast): + # Conservatively deny constant proofs for a name written anywhere. + if isinstance(node, Assignment) and isinstance(node.target, Identifier): + self.reassigned_names.add(node.target.name) if isinstance(node, (VarDecl, TupleAssign, ForStmt, FuncDef)): self.declarations[id(node)] = node if isinstance(node, FuncDef): @@ -294,15 +298,58 @@ def array_result(name, spec): class _CounterValueQuery(_CounterQuery): - """Reuse the check recursion with fractional division values and its own memo.""" + """Keep fractional division values unless literal arithmetic proves an int.""" def __init__(self, check_query): super().__init__(check_query.sources, check_query.bindings, check_query.scopes) self.check_query = check_query + self.integer_constants = {} + + def _integer_constant(self, node): + """Prove an integer using only immutable literal arithmetic.""" + key = id(node) + if key in self.integer_constants: + return self.integer_constants[key] + # Memoize failures too; a provisional failure breaks alias cycles. + self.integer_constants[key] = None + value = None + if isinstance(node, NumberLiteral) and type(node.value) is int: + value = node.value + elif isinstance(node, Identifier): + binding = self.bindings.get(key) + declaration = self.sources.declarations.get(binding[0]) if binding else None + if (isinstance(declaration, VarDecl) + and not declaration.is_var and not declaration.is_varip + and declaration.name not in self.sources.reassigned_names + and declaration.type_hint in (None, "int") + and (declaration.annotations or {}).get("qualifier") in (None, "const")): + value = self._integer_constant(declaration.value) + elif isinstance(node, UnaryOp) and node.op in {"+", "-"}: + operand = self._integer_constant(node.operand) + if operand is not None: + value = -operand if node.op == "-" else operand + elif isinstance(node, BinOp) and node.op in {"+", "-", "*", "/", "%"}: + left = self._integer_constant(node.left) + right = self._integer_constant(node.right) + if left is not None and right is not None: + if node.op == "+": + value = left + right + elif node.op == "-": + value = left - right + elif node.op == "*": + value = left * right + elif right != 0: + if node.op == "/" and left % right == 0: + value = left // right + elif node.op == "%": + remainder = abs(left) % abs(right) + value = -remainder if left < 0 else remainder + self.integer_constants[key] = value + return value def infer(self, node): if isinstance(node, BinOp) and node.op == "/": - return _FLOAT + return _INT if self._integer_constant(node) is not None else _FLOAT return super().infer(node) def counter_spec(self, start, step, **kwargs): diff --git a/tests/_for_float_step.py b/tests/_for_float_step.py index 8f061b63..f9c6b475 100644 --- a/tests/_for_float_step.py +++ b/tests/_for_float_step.py @@ -9,25 +9,26 @@ DIVISION_STARTS = { - "literal-division": ("", "4 / 2"), - "named-division": ("s = 4 / 2\n", "s"), - "helper-division": ("startValue() => 4 / 2\n", "startValue()"), - "series-division": ("", "bar_index / 2"), + "literal-division": ("", "4 / 2", "int"), + "named-division": ("s = 4 / 2\n", "s", "int"), + "helper-division": ("startValue() => 4 / 2\n", "startValue()", "int"), + "series-division": ("", "bar_index / 2", "double"), } -# prefix, start, end, float-start twin, twin end. The default-step sums are -# 10.5 for odd n and 12 for even n; the integral constant sums to 20. +# prefix, start, end, value twin, twin end, counter family. Twins share all +# declarations and the end expression, differing only in the start expression. +# The default-step sums are 10.5 for odd n and 12 for even n; the integral +# constant sums to 20. DIVISION_VALUE_STARTS = { "fractional-input": ( - "n = input.int(5)\n", "n / 2", "n / 2 + 2", "2.5", "4.5", + "n = input.int(5)\n", "n / 2", "n / 2 + 2", "n * 0.5", "n / 2 + 2", "double", ), "fractional-series": ( - "n = 5 + bar_index % 2\n", "n / 2", "n / 2 + 2", - "(bar_index % 2 == 0 ? 2.5 : 3.0)", - "(bar_index % 2 == 0 ? 4.5 : 5.0)", + "n = 5 + bar_index % 2\n", + "n / 2", "n / 2 + 2", "n * 0.5", "n / 2 + 2", "double", ), - "fractional-constant": ("", "5 / 2", "4.5", "2.5", "4.5"), - "integral-constant": ("", "4 / 2", "6", "2.0", "6"), + "fractional-constant": ("", "5 / 2", "4.5", "2.5", "4.5", "double"), + "integral-constant": ("", "4 / 2", "6", "2", "6", "int"), } DIVISION_VALUE_SUMS = { "fractional-input": (10.5, 10.5), diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index 163c3e47..059938ad 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -343,7 +343,7 @@ def division_runs(tmp_path_factory): base = tmp_path_factory.mktemp("for_division_values") feed = chart_feed_head(engine, base, 32) builds = {} - for name, (prefix, start, end, twin, twin_end) in DIVISION_VALUE_STARTS.items(): + for name, (prefix, start, end, twin, twin_end, _family) in DIVISION_VALUE_STARTS.items(): for suffix, first, last in (("", start, end), ("_twin", twin, twin_end)): source = _source( prefix + "float acc = 0.0\n" diff --git a/tests/test_for_counter_type_sources.py b/tests/test_for_counter_type_sources.py index 2b86a841..eced4746 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -148,14 +148,14 @@ def test_positive_int_parameter_start_refuses_a_float_step(): assert_float_step_refused(pine, counter="walkIndex", location=(5, 37, 41)) -@pytest.mark.parametrize("prefix,start", [ - *(pytest.param(prefix, start, id=name) - for name, (prefix, start) in sorted(DIVISION_STARTS.items())), - *(pytest.param(prefix, start, id=name) - for name, (prefix, start, _end, _twin, _twin_end) +@pytest.mark.parametrize("prefix,start,family", [ + *(pytest.param(prefix, start, family, id=name) + for name, (prefix, start, family) in sorted(DIVISION_STARTS.items())), + *(pytest.param(prefix, start, family, id=name) + for name, (prefix, start, _end, _twin, _twin_end, family) in sorted(DIVISION_VALUE_STARTS.items())), ]) -def test_division_start_has_distinct_check_and_value_specs(prefix, start): +def test_division_start_has_distinct_check_and_value_specs(prefix, start, family): pine = loop_step_source(start, prefix=prefix, step=None) ast = Parser(Lexer(pine).tokenize(), source=pine).parse() ctx = Analyzer(ast).analyze() @@ -165,8 +165,53 @@ def test_division_start_has_distinct_check_and_value_specs(prefix, start): ctx.identifier_decl_bindings, ctx.identifier_binding_scopes, ) assert query.infer(loop.start) == TypeSpec.primitive("int") - assert query.value_spec(loop.start) == TypeSpec.primitive("float") - assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT + expected = "float" if family == "double" else "int" + assert query.value_spec(loop.start) == TypeSpec.primitive(expected) + assert ctx.for_counter_types[id(loop.start)] == ( + PineType.FLOAT if family == "double" else PineType.INT + ) + + +@pytest.mark.parametrize("prefix,start,family", [ + pytest.param("", "(+4) / (-2)", "int", id="parenthesized-signs"), + pytest.param("const int n = 8\nconst int d = 4\n", "n / d", "int", + id="declared-constants"), + pytest.param("unit = 2\nn = unit * unit\nd = +unit\n", "n / d", "int", + id="inferred-constant-aliases"), + pytest.param("", "(3 * 4 + 6 - 2) / (1 + 1)", "int", id="integer-arithmetic"), + pytest.param("", "(8 / 2) / (6 / 3)", "int", id="nested-exact-divisions"), + pytest.param("", "(11 % 4) / 3", "int", id="integer-remainder"), + pytest.param("", "(-11 % 4) / -3", "int", id="negative-remainder"), + pytest.param("", "0 / 2", "int", id="zero-result"), + pytest.param("", "(5 / 2) * 2 / 2", "double", id="fractional-intermediate"), + pytest.param("", "4 / (2 - 2)", "double", id="zero-constant-divisor"), + pytest.param("", "(4 % 0) / 2", "double", id="zero-remainder-divisor"), + pytest.param("n = 4\nn += 1\n", "n / 2", "double", id="reassigned-name"), + pytest.param("var int n = 4\n", "n / 2", "double", id="persistent-name"), + pytest.param("series int n = 4\n", "n / 2", "double", id="series-qualifier"), + pytest.param("simple int n = 4\n", "n / 2", "double", id="simple-qualifier"), + pytest.param("", "int(4) / 2", "double", id="call-operand"), + pytest.param("n = int(4)\n", "n / 2", "double", id="call-alias"), + pytest.param("", "4.0 / 2", "double", id="float-literal"), + pytest.param("float n = 4\n", "n / 2", "double", id="float-alias"), +]) +def test_division_integrality_requires_constant_integer_operands(prefix, start, family): + pine = loop_step_source(start, prefix=prefix, step=None) + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + ctx = Analyzer(ast).analyze() + loop = next(node for node, _ in iter_ast_nodes(ast) if isinstance(node, ForStmt)) + query = _CounterQuery( + LoopTypeSources(ast), + ctx.identifier_decl_bindings, ctx.identifier_binding_scopes, + ) + assert query.value_spec(loop.start) == TypeSpec.primitive( + "int" if family == "int" else "float", + ) + assert ctx.for_counter_types[id(loop.start)] == ( + PineType.INT if family == "int" else PineType.FLOAT + ) + cpp = transpile(pine) + assert re.search(rf"\bfor \({family} i = ", cpp), cpp @pytest.mark.parametrize("context", ["chart", "security"]) @@ -194,17 +239,19 @@ def test_division_start_has_distinct_check_and_value_specs(prefix, start): pytest.param("startValue() => 4 / 2\n", "", "startValue()", "0.5", "refused", id="helper-division-start"), pytest.param("", "", "bar_index / 2", "0.5", "refused", id="series-division-start"), - pytest.param("", "", "4 / 2", "1", "double", id="division-int-step"), - pytest.param("", "begin = 4 / 2\n", "begin", "1", "double", + pytest.param("", "", "4 / 2", "1", "int", id="division-int-step"), + pytest.param("", "begin = 4 / 2\n", "begin", "1", "int", id="named-division-int-step"), - pytest.param("startValue() => 4 / 2\n", "", "startValue()", "1", "double", + # Evaluate the expression-only helper before the loop. Requested helper + # loops cannot contain a user function call in their header or body. + pytest.param("startValue() => 4 / 2\n", "begin = startValue()\n", "begin", "1", "int", id="helper-division-int-step"), pytest.param("n = input.int(5)\n", "", "n / 2", "1", "double", id="input-division-int-step"), pytest.param("", "n = 5 + bar_index % 2\n", "n / 2", "1", "double", id="series-division-int-step"), - # Step checking retains the operand join, even when the value query for - # a start would see the same division as positively float. + # Step checking retains the operand join independently of the constant + # proof used to choose storage for a division in the start. pytest.param("", "", "0", "4 / 2", "int", id="int-division-step"), pytest.param("", "", "first", "4 / 2", "int", id="unknown-start-int-division-step"), pytest.param("", "", "0", "4 / 2.0", "refused", id="float-division-step"), diff --git a/tests/test_for_float_step.py b/tests/test_for_float_step.py index 5370e659..493ff676 100644 --- a/tests/test_for_float_step.py +++ b/tests/test_for_float_step.py @@ -271,20 +271,24 @@ def test_float_step_refusal_uses_the_same_counter_family_as_emission( @pytest.mark.parametrize("name", sorted(DIVISION_STARTS)) def test_int_division_start_still_refuses_a_float_step(name): - prefix, start = DIVISION_STARTS[name] + prefix, start, _family = DIVISION_STARTS[name] assert_float_step_refused(loop_step_source(start, prefix=prefix)) @pytest.mark.parametrize("name", sorted(DIVISION_STARTS)) -def test_division_start_emits_a_float_counter_with_default_step(name): - prefix, start = DIVISION_STARTS[name] +def test_division_start_emits_its_value_family_with_default_step(name): + prefix, start, family = DIVISION_STARTS[name] cpp = transpile(loop_step_source(start, prefix=prefix, step=None)) - assert re.search(r"\bfor \(double i = ", cpp), cpp - assert not re.search(r"\bfor \(int i = ", cpp), cpp + assert re.search(rf"\bfor \({family} i = ", cpp), cpp + other = "int" if family == "double" else "double" + assert not re.search(rf"\bfor \({other} i = ", cpp), cpp for slot in ("start", "end", "step"): - assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp - assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 - assert cpp.count("::kFloatStepTolerance") == 2 + assert re.search(rf"\b{family} _for_{slot}_\d+ = ", cpp), cpp + if family == "double": + assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 + assert cpp.count("::kFloatStepTolerance") == 2 + else: + assert "kFloatStepTolerance" not in cpp @pytest.mark.parametrize("start,end,step", [ @@ -298,9 +302,23 @@ def test_integer_start_arithmetic_still_refuses_a_float_step(start, end, step): assert_float_step_refused(loop_step_source(start, end=end, step=step)) -@pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) +def _normalize_declared_loop_start(cpp, family): + # Only the declaration's initializer may differ. In particular, do not + # match an end refresh in the for header or stop at an inline lambda's ';'. + cpp, count = re.subn( + rf"(?m)^(\s*{family} _for_start_\d+ = )[^\n]*(;\n)", + r"\1\2", cpp, + ) + assert count == 1, cpp + return cpp + + +@pytest.mark.parametrize("name", [ + name for name, row in sorted(DIVISION_VALUE_STARTS.items()) if row[-1] == "double" +]) def test_division_start_without_step_matches_a_float_start(name): - prefix, start, end, twin, twin_end = DIVISION_VALUE_STARTS[name] + prefix, start, end, twin, twin_end, family = DIVISION_VALUE_STARTS[name] + assert end == twin_end sources = [ loop_step_source(start, prefix=prefix, end=end, step=None), loop_step_source(twin, prefix=prefix, end=twin_end, step=None), @@ -308,21 +326,66 @@ def test_division_start_without_step_matches_a_float_start(name): normalized = [] for source in sources: cpp = transpile(source) - # Compare all C++ apart from the authored bound expressions that - # differ. Header types, step, body and tolerance must match. - slots = [("start", 1)] - if end != twin_end: - slots.append(("end", 2)) - for slot, count_expected in slots: - cpp, count = re.subn( - rf"(\b(?:double )?_for_{slot}_\d+ = )[^;\n]+(?=;|\) \{{)", - rf"\1<{slot}>", cpp, - ) - assert count == count_expected, cpp - normalized.append(cpp) + assert re.search(rf"\bfor \({family} i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\b{family} _for_{slot}_\d+ = ", cpp), cpp + assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 + assert cpp.count("::kFloatStepTolerance") == 2 + # All other C++ bytes, including the end, step, body and tolerance, + # must match the twin whose Pine source differs only in the start. + normalized.append(_normalize_declared_loop_start(cpp, family)) assert normalized[0] == normalized[1] +@pytest.mark.parametrize("start,twin", [ + pytest.param("4 / 2", "2", id="four-over-two"), + pytest.param("8 / 4", "2", id="eight-over-four"), + pytest.param("-4 / 2", "-2", id="negative-four-over-two"), +]) +def test_proved_integral_division_matches_an_int_start(start, twin, tmp_path): + reference = reference_codegen(LOOP_CPP_REFERENCE) + assert reference is not None, "Required loop C++ reference unavailable; restore git history." + headers = [] + for first in (start, twin): + source = loop_step_source(first, step=None) + cpp = transpile(source) + assert re.search(r"\bfor \(int i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bint _for_{slot}_\d+ = ", cpp), cpp + assert "kFloatStepTolerance" not in cpp + # Division already emits an na-preserving cast and guards where a + # literal does not. Preserve every byte of BOTH frozen outputs. + pine = tmp_path / "strategy.pine" + pine.write_text(source, encoding="utf-8") + before = transpile_json(pine, root=reference) + assert before["ok"], before["diagnostics"] + assert cpp == before["cpp"] + end = re.search(r"(?m)^ *int _for_end_\d+ = (.+);$", cpp) + step = re.search(r"(?m)^ *int _for_step_\d+ = (.+);$", cpp) + body = re.search(r"(?m)^ *for \(int i = [^\n]+\{\n(?P.*?)^ *}", cpp, re.S) + assert end and step and body, cpp + headers.append((end[1], step[1], body["body"])) + assert headers[0] == headers[1] + + +@pytest.mark.parametrize("prefix,start", [ + pytest.param("", "5 / 2", id="five-over-two"), + pytest.param("", "4 / 3", id="four-over-three"), + pytest.param("n = input.int(5)\n", "n / 2", id="input"), + pytest.param("n = 5 + bar_index % 2\n", "n / 2", id="series"), + pytest.param("", "4 / 0", id="zero-divisor"), +]) +def test_unproved_division_uses_double_headers(prefix, start): + cpp = transpile(loop_step_source(start, prefix=prefix, step=None)) + assert re.search(r"\bfor \(double i = ", cpp), cpp + assert not re.search(r"\bfor \(int i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp + assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 + assert cpp.count("::kFloatStepTolerance") == 2 + assert_float_step_refused(loop_step_source(start, prefix=prefix)) + + @pytest.mark.parametrize("start,end", [ pytest.param("2", "6", id="int-literal"), pytest.param("1 + 1", "6", id="int-addition"), @@ -350,7 +413,7 @@ def test_integer_counter_keeps_frozen_cpp(start, end, tmp_path): @pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) def test_division_start_preserves_counter_values(name): - prefix, start, end, _twin, _twin_end = DIVISION_VALUE_STARTS[name] + prefix, start, end, _twin, _twin_end, _family = DIVISION_VALUE_STARTS[name] expected = ",".join(str(value) for value in DIVISION_VALUE_SUMS[name]) driver = r''' #include From 813805f80d64f8fe20044e7170757356a93ee4fc Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 23:11:55 +0800 Subject: [PATCH 11/14] Type ta.highest and ta.lowest as float in the counter check; add TradingView-tape rows and carrier rows for the fractional start TradingView accepts `ta.highest(bar_index, 3)` and `ta.lowest(bar_index, 3)` as the start of a loop with a float step (their result is a float), but the check typed the call from its source argument, so an int source made it refuse a valid script. highest and lowest now give the float type for every overload. `ta.change` and `ta.valuewhen` stay typed from their source, which TradingView refuses on an int source. `ta.median`, `ta.mode`, `ta.max`, `ta.min`, `ta.range` and `ta.percentile_nearest_rank` have no TradingView receipt yet and are unknown for an int source until one is recorded. The measured quantity of each fractional-start fixture is now compared with the recorded TradingView entry quantity, exactly, from the measurement fixture: the five division forms, the fractional input and the fractional series. Eight carrier forms (reassignment, ternary, if and switch arms, helper return, helper parameter, tuple element, array element) and six integer controls are pinned. Two carriers still drop the fraction (a reassigned name and a helper parameter fed the division): they are named gap tests that assert the current value and flip when the gap closes. Co-Authored-By: Claude Sonnet 5.5 --- pineforge_codegen/loop_types.py | 13 ++- tests/_for_float_step.py | 9 +- tests/_for_fractional_carriers.py | 91 +++++++++++++++ .../division_counter_value.pine | 10 ++ .../division_end_only.pine | 10 ++ .../division_named_start.pine | 12 ++ .../division_start_int_step.pine | 10 ++ .../division_start_no_step.pine | 10 ++ .../for_counter_headers/fractional_input.pine | 10 ++ .../fractional_series.pine | 10 ++ .../tradingview_quantities.json | 23 +++- tests/test_e2e_for_counter_headers.py | 109 ++++++++++++------ tests/test_for_counter_type_sources.py | 90 +++++++++++++++ tests/test_for_float_step.py | 60 +++++++--- 14 files changed, 404 insertions(+), 63 deletions(-) create mode 100644 tests/_for_fractional_carriers.py create mode 100644 tests/fixtures/for_counter_headers/division_counter_value.pine create mode 100644 tests/fixtures/for_counter_headers/division_end_only.pine create mode 100644 tests/fixtures/for_counter_headers/division_named_start.pine create mode 100644 tests/fixtures/for_counter_headers/division_start_int_step.pine create mode 100644 tests/fixtures/for_counter_headers/division_start_no_step.pine create mode 100644 tests/fixtures/for_counter_headers/fractional_input.pine create mode 100644 tests/fixtures/for_counter_headers/fractional_series.pine diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 10e24a9a..0fcab9fc 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -34,7 +34,7 @@ "linreg", "rsi", "stoch", "cci", "mfi", "mom", "roc", "cmo", "tsi", "wpr", "cog", "bbw", "kcw", "atr", "tr", "stdev", "variance", "sar", "percentrank", "percentile_linear_interpolation", - "dev", "cum", "correlation", "rci", + "dev", "cum", "correlation", "rci", "highest", "lowest", }), } _ARRAY_ELEMENTS = frozenset({ @@ -253,11 +253,14 @@ def argument(index, keyword): return self.infer(argument(0, "defval" if name == "input" else "source")) if namespace == "ta" and name in { "change", "valuewhen", "median", "mode", "max", "min", - "highest", "lowest", "range", "percentile_nearest_rank", + "range", "percentile_nearest_rank", }: - if name in {"highest", "lowest"} and len(node.args) == 1 and not node.kwargs: - return _FLOAT # This overload reads the declared float high/low. - return self.infer(argument(1 if name == "valuewhen" else 0, "source")) + source = self.infer(argument(1 if name == "valuewhen" else 0, "source")) + if source == _INT and name not in {"change", "valuewhen"}: + # No TradingView int-result receipt yet for these six calls. + # Leave the check unknown until their overloads are measured. + return None + return source if namespace == "request" and name == "security": return self.infer(argument(2, "expression")) if namespace == "array": diff --git a/tests/_for_float_step.py b/tests/_for_float_step.py index f9c6b475..13d82266 100644 --- a/tests/_for_float_step.py +++ b/tests/_for_float_step.py @@ -17,8 +17,7 @@ # prefix, start, end, value twin, twin end, counter family. Twins share all # declarations and the end expression, differing only in the start expression. -# The default-step sums are 10.5 for odd n and 12 for even n; the integral -# constant sums to 20. +# Measured quantities live in fixtures/for_counter_headers/tradingview_quantities.json. DIVISION_VALUE_STARTS = { "fractional-input": ( "n = input.int(5)\n", "n / 2", "n / 2 + 2", "n * 0.5", "n / 2 + 2", "double", @@ -30,12 +29,6 @@ "fractional-constant": ("", "5 / 2", "4.5", "2.5", "4.5", "double"), "integral-constant": ("", "4 / 2", "6", "2", "6", "int"), } -DIVISION_VALUE_SUMS = { - "fractional-input": (10.5, 10.5), - "fractional-series": (10.5, 12), - "fractional-constant": (10.5, 10.5), - "integral-constant": (20, 20), -} def loop_step_source( diff --git a/tests/_for_fractional_carriers.py b/tests/_for_fractional_carriers.py new file mode 100644 index 00000000..97d394f3 --- /dev/null +++ b/tests/_for_fractional_carriers.py @@ -0,0 +1,91 @@ +"""Derived carriers of the measured fractional_input loop, not new TV tapes.""" + +from textwrap import dedent + + +def _source(body: str) -> str: + return ( + '//@version=6\nstrategy("Fractional start carrier", ' + 'currency=currency.NONE, initial_capital=1000000000000)\n' + + 'n = input.int(5, "Numerator")\n' + + dedent(body).lstrip() + + 'if bar_index > 6 and bar_index % 7 == 3\n' + + ' strategy.entry("L", strategy.long, qty=acc)\n' + + 'if bar_index > 6 and bar_index % 7 == 5\n' + + ' strategy.close("L")\n' + ) + + +# Each loop sums the start, start + 1 and start + 2 with the default step. +# Its oracle is fractional_input in tradingview_quantities.json: 10.5. +# The native tests name any carrier whose storage still loses that fraction. +CARRIER_SOURCES = { + "reassignment": _source(""" + s = n + s := s / 2 + float acc = 0.0 + for i = s to s + 2 + acc += i + """), + "ternary_arm": _source(""" + cond = true + s = cond ? n / 2 : n / 2 + float acc = 0.0 + for i = s to s + 2 + acc += i + """), + "if_arm": _source(""" + cond = true + s = if cond + n / 2 + else + n / 2 + float acc = 0.0 + for i = s to s + 2 + acc += i + """), + "switch_arm": _source(""" + cond = true + s = switch cond + true => n / 2 + => n / 2 + float acc = 0.0 + for i = s to s + 2 + acc += i + """), + "helper_return": _source(""" + start_value() => n / 2 + s = start_value() + float acc = 0.0 + for i = s to s + 2 + acc += i + """), + "helper_parameter": _source(""" + count_from(s) => + float subtotal = 0.0 + for i = s to s + 2 + subtotal += i + subtotal + float acc = count_from(n / 2) + """), + "tuple_element": _source(""" + pair() => [n / 2, 1] + [a, b] = pair() + float acc = 0.0 + for i = a to a + 2 + acc += i + """), + "array_element": _source(""" + arr = array.from(n / 2) + s = array.get(arr, 0) + float acc = 0.0 + for i = s to s + 2 + acc += i + """), +} + +CARRIER_FAMILIES = { + name: "int" if name in {"reassignment", "helper_parameter"} else "double" + for name in CARRIER_SOURCES +} +KNOWN_CARRIER_GAPS = {"reassignment", "helper_parameter"} diff --git a/tests/fixtures/for_counter_headers/division_counter_value.pine b/tests/fixtures/for_counter_headers/division_counter_value.pine new file mode 100644 index 00000000..7fc74f60 --- /dev/null +++ b/tests/fixtures/for_counter_headers/division_counter_value.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("value probe: counter value used as a quantity", initial_capital = 1000000, currency = currency.NONE) +// 9 / 2 is 4.5; the loop runs once (4.5 to 5): the counter value itself is the quantity: 4.5 as a float counter, 4 as a truncated int counter. +float last = 0.0 +for i = 9 / 2 to 5 + last := i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = last) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/division_end_only.pine b/tests/fixtures/for_counter_headers/division_end_only.pine new file mode 100644 index 00000000..c7129b7a --- /dev/null +++ b/tests/fixtures/for_counter_headers/division_end_only.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("value probe: division only in the end", initial_capital = 1000000, currency = currency.NONE) +// the end 7 / 2 is 3.5; the counter starts at an int 0 and visits 0, 1, 2, 3: sum 6. +float acc = 0.0 +for i = 0 to 7 / 2 + acc := acc + i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1 + acc / 100) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/division_named_start.pine b/tests/fixtures/for_counter_headers/division_named_start.pine new file mode 100644 index 00000000..d27245a3 --- /dev/null +++ b/tests/fixtures/for_counter_headers/division_named_start.pine @@ -0,0 +1,12 @@ +//@version=6 +strategy("value probe: named division start", initial_capital = 1000000, currency = currency.NONE) +// same as v01 through a variable: float counter sum 16, truncated int counter sum 20. +n = 5 +s = n / 2 +float acc = 0.0 +for i = s to 6 + acc := acc + i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1 + acc / 100) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/division_start_int_step.pine b/tests/fixtures/for_counter_headers/division_start_int_step.pine new file mode 100644 index 00000000..71b42164 --- /dev/null +++ b/tests/fixtures/for_counter_headers/division_start_int_step.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("value probe: division start, int step", initial_capital = 1000000, currency = currency.NONE) +// 7 / 2 is 3.5. A float counter visits 3.5, 5.5, 7.5: sum 16.5 (three iterations). An int counter that truncates the start visits 3, 5, 7, 9: sum 24. +float acc = 0.0 +for i = 7 / 2 to 9 by 2 + acc := acc + i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1 + acc / 100) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/division_start_no_step.pine b/tests/fixtures/for_counter_headers/division_start_no_step.pine new file mode 100644 index 00000000..ce25f968 --- /dev/null +++ b/tests/fixtures/for_counter_headers/division_start_no_step.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("value probe: division start, no step", initial_capital = 1000000, currency = currency.NONE) +// 5 / 2 is 2.5. A float counter visits 2.5, 3.5, 4.5, 5.5: sum 16. An int counter that truncates the start visits 2..6: sum 20. +float acc = 0.0 +for i = 5 / 2 to 6 + acc := acc + i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = 1 + acc / 100) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/fractional_input.pine b/tests/fixtures/for_counter_headers/fractional_input.pine new file mode 100644 index 00000000..6f06c394 --- /dev/null +++ b/tests/fixtures/for_counter_headers/fractional_input.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("synthetic fractional division input loop sum", currency=currency.NONE, initial_capital=1000000000000) +n = input.int(5, "Numerator") +float pf_sum = 0 +for i = n / 2 to n / 2 + 2 + pf_sum += i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty=pf_sum) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/fractional_series.pine b/tests/fixtures/for_counter_headers/fractional_series.pine new file mode 100644 index 00000000..e8d8bea2 --- /dev/null +++ b/tests/fixtures/for_counter_headers/fractional_series.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("synthetic fractional division series loop sum", currency=currency.NONE, initial_capital=1000000000000) +n = 5 + bar_index % 2 +float pf_sum = 0 +for i = n / 2 to n / 2 + 2 + pf_sum += i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty=pf_sum) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index 026c077c..bb0a76e4 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -1,5 +1,6 @@ { "provenance": "TradingView measurements, 2026-11-02. Each sha256 is the SHA-256 of that probe's TradingView export: the trades list or the reply that stops the run. Each source_sha256 identifies the measured source, not necessarily the repository fixture. The export files are not stored in the repository. For float_counter_*, first_end_read_*, int_counter_float_end and step_reads_counter_descending, fixtures preserve the measured expressions but may change strategy titles, comments and whitespace; float_counter_helper_value also places the helper body on its declaration line. The float-counter value and thirteen older iteration-count rows were measured on BINANCE:BTCUSDT 60, including seven tolerance-bracket probes at 12:22; each quantity holds on all 205 entries. The four additional large/small-step rows preserve every source byte except the strategy title; counts 3, 4, 3 and 3 are encoded as entry quantities 1.03, 1.04, 1.03 and 1.03 on all 180 exported entries. Their trade schedule is every eight bars from bar 0, with a close on the next bar. The first_end_read_start_two_down and first_end_read_start_two_up probes were measured at 11:57 on BINANCE:BTCUSDT 60; their exports have identical bytes and all 205 entries have quantity 1.1. The zero-entry probe is recorded separately because its accumulator starts at 0. The fstep_* sources are exact copies of the accepted TradingView scripts; their entry quantities encode 5, 7 and 3 loop iterations as 1 + count / 100. Their trade schedule is every eight bars from bar 0, with a close on the next bar. Compile-refusal spans use TradingView's 1-based inclusive end columns.", + "division_provenance": "The division_start_no_step, division_start_int_step, division_named_start, division_end_only and division_counter_value fixtures copy the measured v01 through v05 scripts byte for byte. fractional_input and fractional_series copy the measured fractional-input.pine and fractional-series.pine byte for byte. All seven exports contain 205 entries. The v01 and v03 exports are the same file. The series export alternates odd n (10.5, 103 entries) and even n (12, 102 entries), starting with odd n. Each sha256 below is the supplied full TradingView export digest; the source hashes identify the copied scripts. Carrier controls derive their 10.5 oracle from fractional_input's identical loop shape; they do not claim separate TradingView tapes.", "float_step_tolerance": { "measured_absolute_bracket": "about [5.6e-17, 9.98e-11] in the units of the counter, from double arithmetic over all 18 measured rows", "choice": "kFloatStepTolerance = 1e-12 is chosen inside the measured bracket; it is not a TradingView constant", @@ -46,7 +47,20 @@ "float_counter_mid_step_short_end": 1.03, "fstep_a_param_start": 1.05, "fstep_b_body_local_start": 1.07, - "fstep_c_array_bounds": 1.03 + "fstep_c_array_bounds": 1.03, + "division_start_no_step": 1.16, + "division_start_int_step": 1.165, + "division_named_start": 1.16, + "division_end_only": 1.06, + "division_counter_value": 4.5, + "fractional_input": 10.5 + }, + "entry_quantity_patterns": { + "fractional_series": { + "quantities": [10.5, 12], + "counts": [103, 102], + "order": "Alternating odd n, even n, starting with odd n" + } }, "trade_schedules": { "float_counter_large_step": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, @@ -131,6 +145,13 @@ "fstep_a_param_start": {"sha256": "911608833e6f02374d11915e81985fa0cca14ab88053317ed413551adb64059c", "source_sha256": "096dcb41a868d376eb82fa5bbb7c94269229b0ecbfab6409869caba56fbbd74e"}, "fstep_b_body_local_start": {"sha256": "796704b642eb27b4df7e4867140abae7930ee125fd47c04c8750a6bea1a65920", "source_sha256": "3237f997e24c8cb0cd34d5b5653aee6b8a6674a748f8fa05a8932463371fd52c"}, "fstep_c_array_bounds": {"sha256": "c152179bb7615af5f831fd56aae6791e4c12a8e992a9a690549e1ab8b1d42db7", "source_sha256": "f304b32d237feffb84a75447a6104a61e017823005368a33ac9cab5c326d6a1c"}, + "division_start_no_step": {"source": "division_start_no_step.pine", "measured_source": "v01_div_start_no_step.pine", "sha256": "601626fe43567172236ad8415b1cb0668e2f2f068d9db964505c0eb9ea792492", "source_sha256": "0d8a3e294351e3c077e64874bb69b0a4ebfac415e2763551eeb4391f0888ccab"}, + "division_start_int_step": {"source": "division_start_int_step.pine", "measured_source": "v02_div_start_int_step.pine", "sha256": "227702b9125ac563b483ac9cde191d2fd165a84cd319da505a80e52014b7fd67", "source_sha256": "d632c352de6f15c35836ec9654444223581e688c3be84b4151e2f6b2862395b1"}, + "division_named_start": {"source": "division_named_start.pine", "measured_source": "v03_div_named_start.pine", "sha256": "601626fe43567172236ad8415b1cb0668e2f2f068d9db964505c0eb9ea792492", "source_sha256": "3e98f880302ce87dd0926cbcbee58fa10b2d672bd6e7f6903d3c248d8159def6"}, + "division_end_only": {"source": "division_end_only.pine", "measured_source": "v04_div_in_end_only.pine", "sha256": "ed918b8cab3dce893fa4742c9e623554208b3b0111f5e776c483009ae8029d55", "source_sha256": "ae887887d0ed3e3e3dab066b52f9987e8019ce8da500d96cdf177ce62b206acb"}, + "division_counter_value": {"source": "division_counter_value.pine", "measured_source": "v05_div_start_uses_counter_value.pine", "sha256": "5937a1a604eaccad590bd5c048fc03e2135247b480d43031a792179aca0d3414", "source_sha256": "247254f382f52f5dec88b892aa7fd3b12586a36930d373a19cfd2cf267d534fb"}, + "fractional_input": {"source": "fractional_input.pine", "measured_source": "fractional-input.pine", "sha256": "3aa0f43925521663a43f0421f5f4d8d0cee5f2925eeb09d0d22078c2d75aaa62", "source_sha256": "7d28e6e5a0fcad872ae300e63e096ba8e6573a7329843762360988c52ca0f02b"}, + "fractional_series": {"source": "fractional_series.pine", "measured_source": "fractional-series.pine", "sha256": "1f5d502ce06983f69a6c3df3292179fa69a5de3cb423fee0beff66d97def5bdb", "source_sha256": "38921b7dc9bcd94450bbaa78d352d9546bf6db8e8c602cf86e126570db4ce73b"}, "s1_step_neg_literal": {"sha256": "2cfe027222a5e99a8aea754cf09dd9e67cb38fbf539d0764fef38a7109a9c038", "source_sha256": "1de17a8b9b8778c5c13d2dac528f0e8d01ed04accd42225addb476861c580069"}, "s2_step_zero_literal": {"sha256": "72f8208f27a9c6e25cfc796a39c282aa0a22b7b6df9d51f5e95a2226147262e5", "source_sha256": "7f5d5e82820b27068929c020ad684ee151544b78918cf967ebf56edf49fe5859"}, "s3_step_neg_variable": {"sha256": "288ff78c63007ccaa0297b7f9d762da761811dd003f0c0ff31bc7909b36676e3", "source_sha256": "9a4fb83eb9ab0c57f938060c345673d354bc6dae46097d9309af1d58ced09640"}, diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index 059938ad..1e839e8c 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -8,6 +8,7 @@ from __future__ import annotations import csv +import hashlib import json from pathlib import Path import re @@ -21,12 +22,23 @@ Build, assert_same_runs, chart_feed_head, execute_all, ok, skip_unless_e2e_env, ) from tests._for_counter_header_cases import STOP_SOURCES -from tests._for_float_step import DIVISION_VALUE_STARTS, DIVISION_VALUE_SUMS +from tests._for_fractional_carriers import CARRIER_SOURCES, KNOWN_CARRIER_GAPS FIXTURES = Path(__file__).parent / "fixtures" / "for_counter_headers" TV = json.loads((FIXTURES / "tradingview_quantities.json").read_text(encoding="utf-8")) MEASURED = tuple(TV["entry_quantities"]) +PATTERN_MEASURED = tuple(TV["entry_quantity_patterns"]) +# These twins change authored Pine expressions, never emitted C++ or a tape. +DIVISION_TAPE_TWINS = { + "division_start_no_step": ("for i = 5 / 2 to", "for i = 2.5 to"), + "division_start_int_step": ("for i = 7 / 2 to", "for i = 3.5 to"), + "division_named_start": ("s = n / 2", "s = 2.5"), + "division_end_only": ("for i = 0 to 7 / 2", "for i = 0 to 3.5"), + "division_counter_value": ("for i = 9 / 2 to", "for i = 4.5 to"), + "fractional_input": ("for i = n / 2 to", "for i = n * 0.5 to"), + "fractional_series": ("for i = n / 2 to", "for i = n * 0.5 to"), +} # Shared parameter inference still truncates this float counter. Run it as # a documented discrepancy, retaining TradingView's measured 5 in the quantities file. HELPER_VALUE_GAP = "float_counter_helper_value" @@ -180,7 +192,7 @@ def runs(tmp_path_factory): # schedule closes its final entry at bar 1442, so include that bar too. feed = chart_feed_head(engine, base, FEED_BARS) builds = {} - for name in (*MEASURED, *ZERO_ENTRY_PROBES): + for name in (*MEASURED, *PATTERN_MEASURED, *ZERO_ENTRY_PROBES): # Includes the float-start probes with TradingView's measured # quantities and export hashes in tradingview_quantities.json. pine = (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") @@ -190,6 +202,10 @@ def runs(tmp_path_factory): rf"\b{counters[-1] if counters else 'i'}\b", "sampleIndex", pine, ) builds[f"{name}_renamed"] = Build(renamed) + if name in DIVISION_TAPE_TWINS: + before, after = DIVISION_TAPE_TWINS[name] + assert pine.count(before) == 1, name + builds[f"{name}_twin"] = Build(pine.replace(before, after)) if name in FIRST_END_READ_PROBES: # A requested-context composition of the measured chart source; # this is derived coverage, not an additional TradingView tape. @@ -204,6 +220,8 @@ def runs(tmp_path_factory): for name, (pine, _) in CONTROLS.items(): overrides = {"First": 4} if name == "nonzero_input_start" else None builds[name] = Build(pine, overrides=overrides) + for name, pine in CARRIER_SOURCES.items(): + builds[f"carrier_{name}"] = Build(pine) for name, pine in DERIVED_FLOAT_SOURCES.items(): builds[name] = Build(pine) for name, pine in NONFINITE_FLOAT_SOURCES.items(): @@ -236,7 +254,7 @@ def _assert_quantities( def test_tradingview_evidence_has_complete_sha256(): - for name in (*MEASURED, *ZERO_ENTRY_PROBES, *TV["runtime_errors"]): + for name in (*MEASURED, *PATTERN_MEASURED, *ZERO_ENTRY_PROBES, *TV["runtime_errors"]): for field in ("sha256", "source_sha256"): assert re.fullmatch( r"[0-9a-fA-F]{64}", TV["evidence"][name][field] or "" @@ -248,6 +266,15 @@ def test_tradingview_evidence_has_complete_sha256(): ), (name, field) +@pytest.mark.parametrize("name", tuple(DIVISION_TAPE_TWINS)) +def test_division_fixture_is_the_measured_source(name): + evidence = TV["evidence"][name] + assert evidence["source"] == f"{name}.pine" + assert hashlib.sha256((FIXTURES / evidence["source"]).read_bytes()).hexdigest() == ( + evidence["source_sha256"] + ) + + @pytest.mark.parametrize("name", tuple( name for name in MATCHING_MEASURED if name not in ABSOLUTE_TOLERANCE_BOUNDARY_PROBES @@ -337,38 +364,54 @@ def test_counter_binding_compositions(runs, name): _assert_quantities(outcome, tag, expected) -@pytest.fixture(scope="module") -def division_runs(tmp_path_factory): - engine = skip_unless_e2e_env() - base = tmp_path_factory.mktemp("for_division_values") - feed = chart_feed_head(engine, base, 32) - builds = {} - for name, (prefix, start, end, twin, twin_end, _family) in DIVISION_VALUE_STARTS.items(): - for suffix, first, last in (("", start, end), ("_twin", twin, twin_end)): - source = _source( - prefix + "float acc = 0.0\n" - f"for i = {first} to {last}\n" - " acc += i\n" - ) + "// @pf-trace acc=acc\n" - builds[name + suffix] = Build(source, trace=True) - return execute_all(engine, feed, base, builds) - - -@pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) -def test_division_start_default_step_values_and_trades(division_runs, name): - outcome = ok(division_runs, name) - assert_same_runs(outcome, ok(division_runs, name + "_twin")) - records = outcome.traces["default"] - assert len(records) == 32 - sums = DIVISION_VALUE_SUMS[name] - for bar, record in enumerate(records): - assert record["name"] == "acc" - assert record["bar_index"] == bar - assert record["value"] == sums[bar % 2], record +@pytest.mark.parametrize("name", tuple(DIVISION_TAPE_TWINS)) +def test_division_tape_fixture_keeps_its_counter_family_and_twin(runs, name): + pine = (FIXTURES / f"{name}.pine").read_text(encoding="utf-8") + cpp = transpile(pine) + family = "int" if name == "division_end_only" else "double" + assert re.search(rf"\bfor \({family} i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\b{family} _for_{slot}_\d+ = ", cpp), cpp + # Scalar tape quantities are checked by test_tradingview_entry_quantities; + # the series tape's ordered quantities are checked separately below. + assert_same_runs(ok(runs, name), ok(runs, name + "_twin")) + + +@pytest.mark.parametrize("name", PATTERN_MEASURED) +@pytest.mark.parametrize("suffix", ["", "_renamed", "_twin"]) +def test_tradingview_alternating_entry_quantities(runs, name, suffix): + outcome = ok(runs, name + suffix) rows = csv.DictReader(outcome.trades["default"].decode("utf-8-sig").splitlines()) entries = [row for row in rows if row["Type"].startswith("Entry")] - expected_quantities = [sums[bar % 2] for bar in (10, 17, 24)] - assert [float(row["Qty"]) for row in entries] == expected_quantities + pattern = TV["entry_quantity_patterns"][name] + quantities = [float(row["Qty"]) for row in entries] + assert len(quantities) == TV["entries_per_probe"] + assert quantities == [ + pattern["quantities"][index % len(pattern["quantities"])] + for index in range(TV["entries_per_probe"]) + ], TV["evidence"][name]["sha256"] + assert [quantities.count(qty) for qty in pattern["quantities"]] == pattern["counts"] + + +@pytest.mark.parametrize("name", sorted(CARRIER_SOURCES.keys() - KNOWN_CARRIER_GAPS)) +def test_fractional_start_carrier_matches_the_measured_loop_shape(runs, name): + """Derived from fractional_input's tape for the same default-step sum.""" + _assert_quantities( + ok(runs, f"carrier_{name}"), "default", + TV["entry_quantities"]["fractional_input"], probe="fractional_input", + ) + + +def test_known_gap_reassignment_fraction(runs): + """TradingView gives 10.5 and we give 9; flips when reassignment keeps the fraction.""" + assert TV["entry_quantities"]["fractional_input"] == 10.5 + _assert_quantities(ok(runs, "carrier_reassignment"), "default", 9) + + +def test_known_gap_helper_parameter_fraction(runs): + """TradingView gives 10.5 and we give 9; flips when a helper parameter keeps the fraction.""" + assert TV["entry_quantities"]["fractional_input"] == 10.5 + _assert_quantities(ok(runs, "carrier_helper_parameter"), "default", 9) STOP_DRIVER = r''' diff --git a/tests/test_for_counter_type_sources.py b/tests/test_for_counter_type_sources.py index eced4746..5c2e8051 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -148,6 +148,96 @@ def test_positive_int_parameter_start_refuses_a_float_step(): assert_float_step_refused(pine, counter="walkIndex", location=(5, 37, 41)) +def _analyzed_counter_start(pine): + ast = Parser(Lexer(pine).tokenize(), source=pine).parse() + ctx = Analyzer(ast).analyze() + loop = next(node for node, _ in iter_ast_nodes(ast) if isinstance(node, ForStmt)) + query = _CounterQuery( + LoopTypeSources(ast), + ctx.identifier_decl_bindings, ctx.identifier_binding_scopes, + ) + return loop, ctx, query + + +@pytest.mark.parametrize("name", ["highest", "lowest"]) +@pytest.mark.parametrize("arguments", ["bar_index, 3", "source=bar_index, length=3"]) +def test_highest_and_lowest_int_sources_accept_a_float_step(name, arguments): + # TradingView accepts both overloads: the result is float even on int data. + pine = loop_step_source(f"ta.{name}({arguments})") + loop, ctx, query = _analyzed_counter_start(pine) + assert query.infer(loop.start) == TypeSpec.primitive("float") + assert query.value_spec(loop.start) == TypeSpec.primitive("float") + assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT + cpp = transpile(pine) + assert re.search(r"\bfor \(double i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp + + +@pytest.mark.parametrize("start", [ + "ta.change(bar_index)", + "ta.valuewhen(true, bar_index, 0)", +]) +def test_measured_source_typed_ta_calls_still_refuse_an_int_source_float_step(start): + # TradingView refuses both int-source forms; division value typing must + # not widen their check result to the registry's default float. + assert_float_step_refused(loop_step_source(start)) + pine = loop_step_source(start, step=None) + loop, ctx, query = _analyzed_counter_start(pine) + assert query.infer(loop.start) == TypeSpec.primitive("int") + assert ctx.for_counter_types[id(loop.start)] == PineType.INT + + +@pytest.mark.parametrize("start", [ + "ta.median(bar_index, 3)", # no TradingView receipt yet + "ta.mode(bar_index, 3)", # no TradingView receipt yet + "ta.max(bar_index)", # no TradingView receipt yet + "ta.min(bar_index)", # no TradingView receipt yet + "ta.range(bar_index, 3)", # no TradingView receipt yet + "ta.percentile_nearest_rank(bar_index, 3, 50)", # no TradingView receipt yet +]) +def test_unmeasured_int_source_ta_check_type_stays_temporarily_unknown(start): + pine = loop_step_source(start) + loop, ctx, query = _analyzed_counter_start(pine) + assert query.infer(loop.start) is None + assert query.value_spec(loop.start) is None + assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT + cpp = transpile(pine) + assert re.search(r"\bfor \(double i = ", cpp), cpp + assert not re.search(r"\bfor \(int i = ", cpp), cpp + + +def test_requested_expression_helper_division_start_uses_a_double_counter(): + # The expression-only helper runs before the loop; requested helper loops + # cannot contain a user-function call in their header or body. + pine = """//@version=6 +strategy("Requested fractional start", currency=currency.NONE) +n = input.int(5) +start_value() => n / 2 +measure() => + first = start_value() + float acc = 0.0 + for i = first to first + 2 + acc += i + acc +plot(request.security(syminfo.tickerid, timeframe.period, measure())) +""" + loop, ctx, query = _analyzed_counter_start(pine) + assert query.infer(loop.start) == TypeSpec.primitive("int") + assert query.value_spec(loop.start) == TypeSpec.primitive("float") + assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT + cpp = transpile(pine) + begin = cpp.index("void _eval_security_0(") + evaluator = cpp[begin:cpp.index("void evaluate_security(", begin)] + assert re.search(r"\bfor \(double _sec0_measure_\d+_i = ", evaluator), evaluator + assert not re.search(r"\bfor \(int _sec0_measure_\d+_i = ", evaluator), evaluator + for slot in ("start", "end", "step"): + assert re.search( + rf"\bdouble _sec0_measure_\d+_for_{slot} = ", evaluator, + ), evaluator + assert "::kFloatStepTolerance" in evaluator + + @pytest.mark.parametrize("prefix,start,family", [ *(pytest.param(prefix, start, family, id=name) for name, (prefix, start, family) in sorted(DIVISION_STARTS.items())), diff --git a/tests/test_for_float_step.py b/tests/test_for_float_step.py index 493ff676..88c42db5 100644 --- a/tests/test_for_float_step.py +++ b/tests/test_for_float_step.py @@ -11,9 +11,10 @@ from tests._e2e import reference_codegen, transpile_json from tests._for_counter_header_cases import MAIN_COMMIT as LOOP_CPP_REFERENCE from tests._for_float_step import ( - DIVISION_STARTS, DIVISION_VALUE_STARTS, DIVISION_VALUE_SUMS, + DIVISION_STARTS, DIVISION_VALUE_STARTS, assert_float_step_refused, loop_step_source, ) +from tests._for_fractional_carriers import CARRIER_FAMILIES, CARRIER_SOURCES # TradingView refuses this form. COUNTER_DEPENDENT_STEP = """//@version=6 @@ -386,17 +387,26 @@ def test_unproved_division_uses_double_headers(prefix, start): assert_float_step_refused(loop_step_source(start, prefix=prefix)) -@pytest.mark.parametrize("start,end", [ - pytest.param("2", "6", id="int-literal"), - pytest.param("1 + 1", "6", id="int-addition"), - pytest.param("2 * 1", "6", id="int-multiplication"), - pytest.param("7 % 4", "6", id="int-remainder"), - pytest.param("0", "7 / 2", id="division-only-in-end"), +@pytest.mark.parametrize("prefix,start,end", [ + pytest.param("", "2", "6", id="int-literal"), + pytest.param("", "1 + 1", "6", id="int-addition"), + pytest.param("", "2 * 1", "6", id="int-multiplication"), + pytest.param("", "7 % 4", "6", id="int-remainder"), + pytest.param("", "0", "7 / 2", id="division-only-in-end"), + pytest.param("n = input.int(5)\n", "int(n / 2)", "6", id="explicit-int-division"), + pytest.param("n = input.int(5)\n", "math.floor(n / 2)", "6", id="floor-division"), + pytest.param("n = input.int(5)\n", "math.round(n / 2)", "6", id="round-division"), + pytest.param("n = input.int(5)\n", "n % 2", "6", id="input-remainder"), + pytest.param("n = input.int(5)\n", "n * 2", "12", id="input-product"), + pytest.param("a = array.from(1, 2, 3, 4, 5)\n", "0", "array.size(a) / 2", + id="array-size-division-only-in-end"), ]) -def test_integer_counter_keeps_frozen_cpp(start, end, tmp_path): - source = loop_step_source(start, end=end, step=None) +def test_integer_counter_keeps_frozen_cpp(prefix, start, end, tmp_path): + source = loop_step_source(start, prefix=prefix, end=end, step=None) cpp = transpile(source) assert re.search(r"\bfor \(int i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bint _for_{slot}_\d+ = ", cpp), cpp assert "kFloatStepTolerance" not in cpp # Use the loop inventory's immutable reference, without replacing any # stored hash or excluding any emitted text from the digest. @@ -412,9 +422,8 @@ def test_integer_counter_keeps_frozen_cpp(start, end, tmp_path): @pytest.mark.parametrize("name", sorted(DIVISION_VALUE_STARTS)) -def test_division_start_preserves_counter_values(name): - prefix, start, end, _twin, _twin_end, _family = DIVISION_VALUE_STARTS[name] - expected = ",".join(str(value) for value in DIVISION_VALUE_SUMS[name]) +def test_division_start_preserves_its_twins_counter_values(name): + prefix, start, end, twin, twin_end, _family = DIVISION_VALUE_STARTS[name] driver = r''' #include #include @@ -431,10 +440,29 @@ def test_division_start_preserves_counter_values(name): std::cout << first_bar.acc << "," << second_bar.acc; } ''' - assert compile_env.run_emitted_tu( - transpile(loop_step_source(start, prefix=prefix, end=end, step=None)), - driver, opt="-O2", label=name, - ) == expected + # This is a differential control. The actual per-entry quantity oracle is + # the recorded TradingView export in test_e2e_for_counter_headers. + outputs = [ + compile_env.run_emitted_tu( + transpile(loop_step_source(first, prefix=prefix, end=last, step=None)), + driver, opt="-O2", label=name + suffix, + ) + for suffix, first, last in (("", start, end), ("_twin", twin, twin_end)) + ] + assert outputs[0] == outputs[1] + + +@pytest.mark.parametrize("name", sorted(CARRIER_SOURCES)) +def test_fractional_carrier_keeps_its_current_counter_family(name): + # A double counter alone cannot establish value parity: the separately + # named native gaps catch a fraction lost before the loop starts. + family = CARRIER_FAMILIES[name] + cpp = transpile(CARRIER_SOURCES[name]) + assert re.search(rf"\bfor \({family} i = ", cpp), cpp + other = "int" if family == "double" else "double" + assert not re.search(rf"\bfor \({other} i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\b{family} _for_{slot}_\d+ = ", cpp), cpp @pytest.mark.parametrize("argument", ["0", "0.0", "4 / 2"]) From 0f0826fa7c2a7c036fab33a225bfe9a70af3d74e Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 23:20:45 +0800 Subject: [PATCH 12/14] Pin three more TradingView tapes for the fractional counter; set the ta calls to the receipts TradingView returned TradingView accepts a descending bound `n / 2 to 0`, a fractional array index (truncated) and `str.tostring` of the fractional counter. Three fixtures copied from those scripts are compared with the recorded TradingView entry counts and quantities (205 entries of 4.5, 205 of 120, 615 of 1) and must translate with a double counter. The string fixture also pins the entry ids; our formatter prints 15 places where TradingView prints the 18 asked for, so that row is a named gap test that asserts the ids we produce today and flips when the formatter is fixed. The ta calls in the loop-start check follow TradingView's compile verdicts for an int source with a float step: `ta.median`, `ta.mode`, `ta.range` and `ta.change` give an int and the float step is refused; `ta.max`, `ta.min`, `ta.percentile_nearest_rank`, `ta.highest` and `ta.lowest` give a float and the loop translates with the double counter. The temporary unknown for the six calls without a receipt is removed. Co-Authored-By: Claude Sonnet 5.5 --- pineforge_codegen/loop_types.py | 11 +--- .../for_counter_headers/counter_string.pine | 8 +++ .../fractional_array_index.pine | 11 ++++ .../fractional_descending.pine | 10 ++++ .../tradingview_quantities.json | 43 +++++++++++++- tests/test_e2e_for_counter_headers.py | 59 ++++++++++++++++++- tests/test_for_counter_type_sources.py | 46 +++++++++++---- 7 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 tests/fixtures/for_counter_headers/counter_string.pine create mode 100644 tests/fixtures/for_counter_headers/fractional_array_index.pine create mode 100644 tests/fixtures/for_counter_headers/fractional_descending.pine diff --git a/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 0fcab9fc..711fca3a 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -35,6 +35,7 @@ "tsi", "wpr", "cog", "bbw", "kcw", "atr", "tr", "stdev", "variance", "sar", "percentrank", "percentile_linear_interpolation", "dev", "cum", "correlation", "rci", "highest", "lowest", + "max", "min", "percentile_nearest_rank", }), } _ARRAY_ELEMENTS = frozenset({ @@ -252,15 +253,9 @@ def argument(index, keyword): if namespace is None and name in {"nz", "fixnan", "input"}: return self.infer(argument(0, "defval" if name == "input" else "source")) if namespace == "ta" and name in { - "change", "valuewhen", "median", "mode", "max", "min", - "range", "percentile_nearest_rank", + "change", "valuewhen", "median", "mode", "range", }: - source = self.infer(argument(1 if name == "valuewhen" else 0, "source")) - if source == _INT and name not in {"change", "valuewhen"}: - # No TradingView int-result receipt yet for these six calls. - # Leave the check unknown until their overloads are measured. - return None - return source + return self.infer(argument(1 if name == "valuewhen" else 0, "source")) if namespace == "request" and name == "security": return self.infer(argument(2, "expression")) if namespace == "array": diff --git a/tests/fixtures/for_counter_headers/counter_string.pine b/tests/fixtures/for_counter_headers/counter_string.pine new file mode 100644 index 00000000..d9fecde5 --- /dev/null +++ b/tests/fixtures/for_counter_headers/counter_string.pine @@ -0,0 +1,8 @@ +//@version=6 +strategy("synthetic fractional counter string", currency=currency.NONE, initial_capital=1000000000000, pyramiding=4) +n = input.int(5, "Numerator") +for i = n / 2 to n / 2 + 2 + if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("i=" + str.tostring(i, "0.000000000000000000"), strategy.long, qty=1) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close_all() diff --git a/tests/fixtures/for_counter_headers/fractional_array_index.pine b/tests/fixtures/for_counter_headers/fractional_array_index.pine new file mode 100644 index 00000000..27b7d16a --- /dev/null +++ b/tests/fixtures/for_counter_headers/fractional_array_index.pine @@ -0,0 +1,11 @@ +//@version=6 +strategy("synthetic fractional counter array index", currency=currency.NONE, initial_capital=1000000000000) +n = input.int(5, "Numerator") +array pf_values = array.from(10.0, 20.0, 30.0, 40.0, 50.0, 60.0) +float pf_sum = 0 +for i = n / 2 to n / 2 + 2 + pf_sum += array.get(pf_values, i) +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty=pf_sum) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/fractional_descending.pine b/tests/fixtures/for_counter_headers/fractional_descending.pine new file mode 100644 index 00000000..a767c91d --- /dev/null +++ b/tests/fixtures/for_counter_headers/fractional_descending.pine @@ -0,0 +1,10 @@ +//@version=6 +strategy("synthetic fractional descending counter", currency=currency.NONE, initial_capital=1000000000000) +n = input.int(5, "Numerator") +float pf_sum = 0 +for i = n / 2 to 0 + pf_sum += i +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty=pf_sum) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index bb0a76e4..5c2534bb 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -1,6 +1,15 @@ { "provenance": "TradingView measurements, 2026-11-02. Each sha256 is the SHA-256 of that probe's TradingView export: the trades list or the reply that stops the run. Each source_sha256 identifies the measured source, not necessarily the repository fixture. The export files are not stored in the repository. For float_counter_*, first_end_read_*, int_counter_float_end and step_reads_counter_descending, fixtures preserve the measured expressions but may change strategy titles, comments and whitespace; float_counter_helper_value also places the helper body on its declaration line. The float-counter value and thirteen older iteration-count rows were measured on BINANCE:BTCUSDT 60, including seven tolerance-bracket probes at 12:22; each quantity holds on all 205 entries. The four additional large/small-step rows preserve every source byte except the strategy title; counts 3, 4, 3 and 3 are encoded as entry quantities 1.03, 1.04, 1.03 and 1.03 on all 180 exported entries. Their trade schedule is every eight bars from bar 0, with a close on the next bar. The first_end_read_start_two_down and first_end_read_start_two_up probes were measured at 11:57 on BINANCE:BTCUSDT 60; their exports have identical bytes and all 205 entries have quantity 1.1. The zero-entry probe is recorded separately because its accumulator starts at 0. The fstep_* sources are exact copies of the accepted TradingView scripts; their entry quantities encode 5, 7 and 3 loop iterations as 1 + count / 100. Their trade schedule is every eight bars from bar 0, with a close on the next bar. Compile-refusal spans use TradingView's 1-based inclusive end columns.", "division_provenance": "The division_start_no_step, division_start_int_step, division_named_start, division_end_only and division_counter_value fixtures copy the measured v01 through v05 scripts byte for byte. fractional_input and fractional_series copy the measured fractional-input.pine and fractional-series.pine byte for byte. All seven exports contain 205 entries. The v01 and v03 exports are the same file. The series export alternates odd n (10.5, 103 entries) and even n (12, 102 entries), starting with odd n. Each sha256 below is the supplied full TradingView export digest; the source hashes identify the copied scripts. Carrier controls derive their 10.5 oracle from fractional_input's identical loop shape; they do not claim separate TradingView tapes.", + "fractional_consumer_provenance": "TradingView accepted descending.pine, array-index.pine and counter-string.pine on 2026-11-02 at 22:59. fractional_descending.pine, fractional_array_index.pine and counter_string.pine copy those sources byte for byte. The descending export has 205 entries of quantity 4.5. The array-index export has 205 entries of quantity 120: indices 2.5, 3.5 and 4.5 truncate to 2, 3 and 4. The counter-string export has 615 entries of quantity 1, with 205 entries for each exact id recorded below. Full export and source digests are the supplied measurement receipts. The counter_tostring known gap records current engine text separately from TradingView's 18-place ids.", + "ta_counter_type_receipt": { + "receipt": "tacarriers-2302", + "sha256": "be854854835eed60f33abe809a60e100cf978a304ba68138a332ef9d1124d1cc", + "int_source_ce10106": ["ta.median", "ta.mode", "ta.range", "ta.change(bar_index, 2)"], + "float_accepted": ["ta.max", "ta.min", "ta.percentile_nearest_rank", "ta.highest", "ta.lowest"], + "compile_only_zero_entries": ["ta.highest(3)"], + "ta.valuewhen": "no receipt, kept as on main" + }, "float_step_tolerance": { "measured_absolute_bracket": "about [5.6e-17, 9.98e-11] in the units of the counter, from double arithmetic over all 18 measured rows", "choice": "kFloatStepTolerance = 1e-12 is chosen inside the measured bracket; it is not a TradingView constant", @@ -8,6 +17,11 @@ "measurement": "Measurements at very large and very small steps rule out an allowance relative to the step or to the end. An absolute allowance of 1e-10 fails float_counter_mid_step_short_end; 1e-17 fails float_counter_tenth_step_to_three_tenths and float_counter_fifth_step_to_three_fifths. The large-magnitude row does not constrain the allowance in this bracket." }, "entries_per_probe": 205, + "entry_counts": { + "fractional_descending": 205, + "fractional_array_index": 205, + "counter_string": 615 + }, "entry_quantities": { "end_reads_counter": 1, "end_reads_counter_other": 3, @@ -53,7 +67,10 @@ "division_named_start": 1.16, "division_end_only": 1.06, "division_counter_value": 4.5, - "fractional_input": 10.5 + "fractional_input": 10.5, + "fractional_descending": 4.5, + "fractional_array_index": 120, + "counter_string": 1 }, "entry_quantity_patterns": { "fractional_series": { @@ -62,6 +79,16 @@ "order": "Alternating odd n, even n, starting with odd n" } }, + "entry_id_patterns": { + "counter_string": { + "ids": [ + "i=2.500000000000000000", + "i=3.500000000000000000", + "i=4.500000000000000000" + ], + "counts": [205, 205, 205] + } + }, "trade_schedules": { "float_counter_large_step": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, "float_counter_small_step": {"period": 8, "first_entry_bar": 0, "close_delay": 1}, @@ -93,7 +120,16 @@ } }, "known_gaps": { - "untyped_parameter_start": "For f(x) containing for v = x to 1.0 by 0.25, TradingView refuses f(0) with CE10106 (helper_int_argument_float_step) and accepts f(0.0). PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call." + "untyped_parameter_start": "For f(x) containing for v = x to 1.0 by 0.25, TradingView refuses f(0) with CE10106 (helper_int_argument_float_step) and accepts f(0.0). PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call.", + "counter_tostring": { + "probe": "counter_string", + "reason": "The emitted numeric formatter caps max_fraction at 15 and then caps min_fraction to it, so the double counter keeps its fraction but prints 15 fractional places instead of TradingView's measured 18. Fixing the shared formatter is outside the counter query and counter emission.", + "current_entry_ids": [ + "i=2.500000000000000", + "i=3.500000000000000", + "i=4.500000000000000" + ] + } }, "runtime_errors": { "step_reads_counter_descending": "RE10021: 'step' in loop must be greater than zero", @@ -152,6 +188,9 @@ "division_counter_value": {"source": "division_counter_value.pine", "measured_source": "v05_div_start_uses_counter_value.pine", "sha256": "5937a1a604eaccad590bd5c048fc03e2135247b480d43031a792179aca0d3414", "source_sha256": "247254f382f52f5dec88b892aa7fd3b12586a36930d373a19cfd2cf267d534fb"}, "fractional_input": {"source": "fractional_input.pine", "measured_source": "fractional-input.pine", "sha256": "3aa0f43925521663a43f0421f5f4d8d0cee5f2925eeb09d0d22078c2d75aaa62", "source_sha256": "7d28e6e5a0fcad872ae300e63e096ba8e6573a7329843762360988c52ca0f02b"}, "fractional_series": {"source": "fractional_series.pine", "measured_source": "fractional-series.pine", "sha256": "1f5d502ce06983f69a6c3df3292179fa69a5de3cb423fee0beff66d97def5bdb", "source_sha256": "38921b7dc9bcd94450bbaa78d352d9546bf6db8e8c602cf86e126570db4ce73b"}, + "fractional_descending": {"source": "fractional_descending.pine", "measured_source": "descending.pine", "sha256": "911f22a9365acf1daf33a3f6adb0bdf46142c7f062b7c0b3ba317b77b503b129", "source_sha256": "0bb92412d12cd18f348ba999e32d90ed018f966d8e480d923b881b6b899f5ce2"}, + "fractional_array_index": {"source": "fractional_array_index.pine", "measured_source": "array-index.pine", "sha256": "8b1940bad020b14dfcacfc8193586c3304bcb3224f63c835082914670d106637", "source_sha256": "b7cd99e805d2377cd596042a9ee57b24133562924ac844403ad7dd15b26dd866"}, + "counter_string": {"source": "counter_string.pine", "measured_source": "counter-string.pine", "sha256": "ea2e0040e9a2bac517dd9e593c07ef3e26727a9ac47dda83735fa25a271d739d", "source_sha256": "adbf627d72b6343805dc18fbd4760c88e540cc4c3fc409caf8fdceb0f24f3b26"}, "s1_step_neg_literal": {"sha256": "2cfe027222a5e99a8aea754cf09dd9e67cb38fbf539d0764fef38a7109a9c038", "source_sha256": "1de17a8b9b8778c5c13d2dac528f0e8d01ed04accd42225addb476861c580069"}, "s2_step_zero_literal": {"sha256": "72f8208f27a9c6e25cfc796a39c282aa0a22b7b6df9d51f5e95a2226147262e5", "source_sha256": "7f5d5e82820b27068929c020ad684ee151544b78918cf967ebf56edf49fe5859"}, "s3_step_neg_variable": {"sha256": "288ff78c63007ccaa0297b7f9d762da761811dd003f0c0ff31bc7909b36676e3", "source_sha256": "9a4fb83eb9ab0c57f938060c345673d354bc6dae46097d9309af1d58ced09640"}, diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index 1e839e8c..a7f4b0a0 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -7,6 +7,7 @@ from __future__ import annotations +from collections import Counter import csv import hashlib import json @@ -19,7 +20,8 @@ from tests import _compile as compile_env from pineforge_codegen import transpile from tests._e2e import ( - Build, assert_same_runs, chart_feed_head, execute_all, ok, skip_unless_e2e_env, + Build, assert_same_runs, chart_feed_head, closed_trades, execute_all, ok, + skip_unless_e2e_env, ) from tests._for_counter_header_cases import STOP_SOURCES from tests._for_fractional_carriers import CARRIER_SOURCES, KNOWN_CARRIER_GAPS @@ -29,6 +31,9 @@ TV = json.loads((FIXTURES / "tradingview_quantities.json").read_text(encoding="utf-8")) MEASURED = tuple(TV["entry_quantities"]) PATTERN_MEASURED = tuple(TV["entry_quantity_patterns"]) +FRACTIONAL_COUNTER_TAPES = ( + "fractional_descending", "fractional_array_index", "counter_string", +) # These twins change authored Pine expressions, never emitted C++ or a tape. DIVISION_TAPE_TWINS = { "division_start_no_step": ("for i = 5 / 2 to", "for i = 2.5 to"), @@ -38,6 +43,9 @@ "division_counter_value": ("for i = 9 / 2 to", "for i = 4.5 to"), "fractional_input": ("for i = n / 2 to", "for i = n * 0.5 to"), "fractional_series": ("for i = n / 2 to", "for i = n * 0.5 to"), + "fractional_descending": ("for i = n / 2 to", "for i = n * 0.5 to"), + "fractional_array_index": ("for i = n / 2 to", "for i = n * 0.5 to"), + "counter_string": ("for i = n / 2 to", "for i = n * 0.5 to"), } # Shared parameter inference still truncates this float counter. Run it as # a documented discrepancy, retaining TradingView's measured 5 in the quantities file. @@ -185,12 +193,18 @@ def _source(body: str) -> str: @pytest.fixture(scope="module") -def runs(tmp_path_factory): +def run_environment(tmp_path_factory): engine = skip_unless_e2e_env() base = tmp_path_factory.mktemp("for_counter_headers") # The seven-bar schedule closes all 205 entries by bar 1441. The eight-bar # schedule closes its final entry at bar 1442, so include that bar too. feed = chart_feed_head(engine, base, FEED_BARS) + return engine, base, feed + + +@pytest.fixture(scope="module") +def runs(run_environment): + engine, base, feed = run_environment builds = {} for name in (*MEASURED, *PATTERN_MEASURED, *ZERO_ENTRY_PROBES): # Includes the float-start probes with TradingView's measured @@ -234,7 +248,7 @@ def _assert_quantities( ) -> None: rows = csv.DictReader(outcome.trades[tag].decode("utf-8-sig").splitlines()) entries = [row for row in rows if row["Type"].startswith("Entry")] - expected_entries = TV["entries_per_probe"] + expected_entries = TV["entry_counts"].get(probe, TV["entries_per_probe"]) schedule = TV["trade_schedules"].get(probe) if schedule is not None: # Orders fill on the next bar. Only entries whose close can fill @@ -264,6 +278,7 @@ def test_tradingview_evidence_has_complete_sha256(): assert re.fullmatch( r"[0-9a-fA-F]{64}", refusal[field] or "" ), (name, field) + assert re.fullmatch(r"[0-9a-fA-F]{64}", TV["ta_counter_type_receipt"]["sha256"]) @pytest.mark.parametrize("name", tuple(DIVISION_TAPE_TWINS)) @@ -275,6 +290,16 @@ def test_division_fixture_is_the_measured_source(name): ) +@pytest.mark.parametrize("name", FRACTIONAL_COUNTER_TAPES) +def test_fractional_counter_tape_translates_with_a_double_counter(name): + # TradingView accepted each exact fixture. This check has no native skip. + cpp = transpile((FIXTURES / f"{name}.pine").read_text(encoding="utf-8")) + assert re.search(r"\bfor \(double i = ", cpp), cpp + assert not re.search(r"\bfor \(int i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp + + @pytest.mark.parametrize("name", tuple( name for name in MATCHING_MEASURED if name not in ABSOLUTE_TOLERANCE_BOUNDARY_PROBES @@ -393,6 +418,34 @@ def test_tradingview_alternating_entry_quantities(runs, name, suffix): assert [quantities.count(qty) for qty in pattern["quantities"]] == pattern["counts"] +@pytest.mark.parametrize("suffix", ["", "_twin"]) +def test_known_gap_counter_tostring(runs, run_environment, suffix): + """TV prints 2.500000000000000000; we print 2.500000000000000 (15 places). + + Quantity and entry counts match the tape; ids are a known formatter gap. + This row must flip to the recorded TV ids when the formatter is fixed. + """ + gap = TV["known_gaps"]["counter_tostring"] + name = gap["probe"] + pattern = TV["entry_id_patterns"][name] + evidence = TV["evidence"][name]["sha256"] + outcome = ok(runs, name + suffix) + _assert_quantities(outcome, "default", TV["entry_quantities"][name], probe=name) + engine, base, feed = run_environment + # engine_trades.csv has no ids. Read the same built strategy through the + # existing native report helper, which exposes each closed trade's id. + entries = closed_trades(engine, base / (name + suffix), feed) + assert len(entries) == TV["entry_counts"][name], evidence + assert all( + entry["qty"] == TV["entry_quantities"][name] for entry in entries + ), evidence + recorded = Counter(dict(zip(pattern["ids"], pattern["counts"]))) + current = Counter(dict(zip(gap["current_entry_ids"], pattern["counts"]))) + actual = Counter(entry["entry_id"] for entry in entries) + assert actual == current, (evidence, actual, current) + assert actual != recorded, (evidence, "formatter gap closed; assert the TV ids") + + @pytest.mark.parametrize("name", sorted(CARRIER_SOURCES.keys() - KNOWN_CARRIER_GAPS)) def test_fractional_start_carrier_matches_the_measured_loop_shape(runs, name): """Derived from fractional_input's tape for the same default-step sum.""" diff --git a/tests/test_for_counter_type_sources.py b/tests/test_for_counter_type_sources.py index 5c2e8051..065854b7 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -176,35 +176,59 @@ def test_highest_and_lowest_int_sources_accept_a_float_step(name, arguments): @pytest.mark.parametrize("start", [ "ta.change(bar_index)", - "ta.valuewhen(true, bar_index, 0)", + "ta.change(bar_index, 2)", # TradingView CE10106, receipt tacarriers-2302 + "ta.median(bar_index, 3)", # TradingView CE10106, receipt tacarriers-2302 + "ta.mode(bar_index, 3)", # TradingView CE10106, receipt tacarriers-2302 + "ta.range(bar_index, 3)", # TradingView CE10106, receipt tacarriers-2302 ]) def test_measured_source_typed_ta_calls_still_refuse_an_int_source_float_step(start): - # TradingView refuses both int-source forms; division value typing must + # TradingView refuses these int-source forms; division value typing must # not widen their check result to the registry's default float. assert_float_step_refused(loop_step_source(start)) pine = loop_step_source(start, step=None) loop, ctx, query = _analyzed_counter_start(pine) assert query.infer(loop.start) == TypeSpec.primitive("int") + assert query.value_spec(loop.start) == TypeSpec.primitive("int") assert ctx.for_counter_types[id(loop.start)] == PineType.INT @pytest.mark.parametrize("start", [ - "ta.median(bar_index, 3)", # no TradingView receipt yet - "ta.mode(bar_index, 3)", # no TradingView receipt yet - "ta.max(bar_index)", # no TradingView receipt yet - "ta.min(bar_index)", # no TradingView receipt yet - "ta.range(bar_index, 3)", # no TradingView receipt yet - "ta.percentile_nearest_rank(bar_index, 3, 50)", # no TradingView receipt yet + "ta.max(bar_index)", # TradingView ACCEPTED, receipt tacarriers-2302 + "ta.min(bar_index)", # TradingView ACCEPTED, receipt tacarriers-2302 + "ta.percentile_nearest_rank(bar_index, 3, 50)", # TradingView ACCEPTED, receipt tacarriers-2302 ]) -def test_unmeasured_int_source_ta_check_type_stays_temporarily_unknown(start): +def test_measured_float_result_ta_calls_accept_an_int_source_float_step(start): pine = loop_step_source(start) loop, ctx, query = _analyzed_counter_start(pine) - assert query.infer(loop.start) is None - assert query.value_spec(loop.start) is None + assert query.infer(loop.start) == TypeSpec.primitive("float") + assert query.value_spec(loop.start) == TypeSpec.primitive("float") assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT cpp = transpile(pine) assert re.search(r"\bfor \(double i = ", cpp), cpp assert not re.search(r"\bfor \(int i = ", cpp), cpp + for slot in ("start", "end", "step"): + assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp + + +def test_highest_one_argument_accepts_a_float_step(): + # TradingView ACCEPTED, receipt tacarriers-2302: compile evidence only, + # with zero entries. This row claims no measured runtime quantity. + pine = loop_step_source("ta.highest(3)") + loop, ctx, query = _analyzed_counter_start(pine) + assert query.infer(loop.start) == TypeSpec.primitive("float") + assert query.value_spec(loop.start) == TypeSpec.primitive("float") + assert ctx.for_counter_types[id(loop.start)] == PineType.FLOAT + assert re.search(r"\bfor \(double i = ", transpile(pine)) + + +def test_valuewhen_keeps_its_existing_source_typed_check(): + # No receipt, kept as on main. + start = "ta.valuewhen(true, bar_index, 0)" + assert_float_step_refused(loop_step_source(start)) + loop, ctx, query = _analyzed_counter_start(loop_step_source(start, step=None)) + assert query.infer(loop.start) == TypeSpec.primitive("int") + assert query.value_spec(loop.start) == TypeSpec.primitive("int") + assert ctx.for_counter_types[id(loop.start)] == PineType.INT def test_requested_expression_helper_division_start_uses_a_double_counter(): From 498d76daffe574d53119a3811c1737f64f7cd797 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Mon, 2 Nov 2026 23:27:15 +0800 Subject: [PATCH 13/14] Move the ta.max and ta.min counter-type rows to the float family TradingView types ta.max and ta.min as float for an int source, so a loop that starts at one of them gets a double counter. Two baseline rows that listed them as int-counter sources, and the three tests that read that list, now expect the double counter and a changed emitted loop. The int rows for ta.median and ta.mode are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- tests/_for_counter_type_source_cases.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/_for_counter_type_source_cases.py b/tests/_for_counter_type_source_cases.py index 1a2f5b39..dfb64769 100644 --- a/tests/_for_counter_type_source_cases.py +++ b/tests/_for_counter_type_source_cases.py @@ -57,14 +57,17 @@ "float_tuple_call", "float_tuple_literal", "for_local_float", + # TradingView types ta.max and ta.min as float even for an int source + # (it accepts them as the start of a float-step loop: compile verdicts + # for ta.max(bar_index) and ta.min(bar_index)), so the counter is a double. + "ta_int_max", + "ta_int_min", "unknown_parameter", } INT_COUNTER_SOURCES = { "int_chain", "int_tuple_call", "int_tuple_literal", - "ta_int_max", "ta_int_median", - "ta_int_min", "ta_int_mode", } From 0dcf40e7069f8f7336dfbd5a3ac44f16c99759de Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Tue, 3 Nov 2026 04:55:48 +0800 Subject: [PATCH 14/14] Count a float-step loop to TradingView's measured end rule instead of a tolerance of ours A counted loop with a float step continued while the counter was within 1e-12 of the end, a constant chosen inside a bracket rather than measured. TradingView's own rule was measured on 72 pre-registered loops (the counter is repeated double addition; the end comparison is the double difference): a counter whose difference to the end is below 1e-10 is still within the loop, counted up to a 9.999e-11 excess and not counted from 1.0e-10, measured at the 0.3 scale and at counter magnitudes of about 1, 1e3, 1e6 and 1e9. The loop now continues while `counter - end < 1e-10` ascending and `end - counter < 1e-10` descending, with the constant named for what it is. No magnitude-independent constant is claimed. The tests read the recorded TradingView counts and run each loop natively, asserting that every entry quantity equals the count TradingView produced; the old structural assertions that spelled the previous constant are removed. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 22 +- CHANGELOG.md | 28 +- CLAUDE.md | 22 +- pineforge_codegen/codegen/base.py | 10 +- pineforge_codegen/codegen/for_loops.py | 15 +- pineforge_codegen/codegen/visit_stmt.py | 6 +- .../c2_end_rule_tapes.json | 599 ++++++++++++++++++ .../tradingview_quantities.json | 9 +- tests/test_e2e_for_counter_headers.py | 121 +++- tests/test_e2e_for_end_rule.py | 57 ++ tests/test_for_counter_headers.py | 60 -- tests/test_for_counter_type_sources.py | 1 - tests/test_for_float_step.py | 59 +- 13 files changed, 827 insertions(+), 182 deletions(-) create mode 100644 tests/fixtures/for_counter_headers/c2_end_rule_tapes.json create mode 100644 tests/test_e2e_for_end_rule.py diff --git a/AGENTS.md b/AGENTS.md index 56c0dcec..7d7c1ef5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -416,15 +416,16 @@ when their result is read in a shape not measured, where TradingView may drop them. Endless loops in functions, methods and `request.security` still never end here while TradingView stops them; data-dependent endless loops still hang. -A float-counter loop accumulates its counter and continues while -`x <= end + kFloatStepTolerance` (the mirrored test when descending), within -an absolute allowance of the end in the units of the counter. -`kFloatStepTolerance = 1e-12` is emitted only when a float-counter loop exists. -It is chosen inside the measured bracket about `[5.6e-17, 9.98e-11]`; -it is not a TradingView constant. Measurements at very large and very small -steps rule out an allowance relative to the step or to the end. +A float-counter loop accumulates its counter by repeated double addition. +TradingView's measured loop-end rule admits a counter whose excess beyond +the end, computed as one double subtraction in the loop's direction, is +strictly below `1e-10`. The named value `kTvFloatStepEnd` is emitted only +when a float-counter loop exists. Measured on TradingView at the 0.3 scale +(counted up to a 9.999e-11 shortfall, not counted from 1.0e-10) and at counter +magnitudes of about 1, 1e3, 1e6 and 1e9; no magnitude-independent constant +is claimed; not a constant TradingView publishes. A positive infinite step leaves the counter beyond the finite bound after -one iteration; the absolute allowance needs no non-finite-step guard. +one iteration; the difference comparison needs no non-finite-step guard. The measured entry-quantity probes in `fixtures/for_counter_headers` include `end_reads_counter` = 1, `end_reads_counter_other` = 3, @@ -443,7 +444,9 @@ zero entries. Non-positive-step probes also measure no-trade stops. The additional inexact-step probes and export hashes are recorded in `tradingview_quantities.json`: `0.0 to 0.3 by 0.1` counts four iterations, while `0.0 to 2999.9999999999 by 1000.0` counts three. These rows reject -absolute allowances of `1e-17` and `1e-10`, respectively. +end comparisons that lose the accumulated last iteration or round the end +up before comparing it. Additional recorded counts exercise the measured +end difference in both directions and at the measured counter magnitudes. Known gaps: a float counter passed to an untyped helper truncates here while TradingView passes the fractional value (`twice(x)` accumulates 2 here versus 5 on TradingView). @@ -454,7 +457,6 @@ For `f(x)` containing `for v = x to 1.0 by 0.25`, TradingView refuses `f(0)` with CE10106 and accepts `f(0.0)`. PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call. -Shortfalls inside the measured tolerance bracket are not pinned by a measurement. Block-local/enclosing-counter bindings, end bounds that change direction during iteration and stops inside `request.security` have no TradingView tape. Integer-counter controls are guarded diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf89e32..a435294c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,17 +58,18 @@ supported as exact pairs; on the 0.x line they are independent. See the A division in a loop start keeps its fraction unless it is a constant division with an exact integer result, which keeps the int route. The float-step refusal follows TradingView's check type (a division of two ints is int). -- Float-counter loops accumulate the counter and continue within an absolute - allowance of the end: `x <= end + kFloatStepTolerance` ascending, with the - mirrored comparison descending. `0.0 to 0.3 by 0.1` counts four iterations. - `kFloatStepTolerance = 1e-12` is emitted only for float-counter loops and - chosen inside the measured bracket about `[5.6e-17, 9.98e-11]` in the units - of the counter; it is not a TradingView constant. Measurements at very large - and very small steps rule out an allowance relative to the step or to the end. - `0.0 to 2999.9999999999 by 1000.0` counts three iterations; together with - the `0.3 by 0.1` row it excludes absolute allowances of `1e-10` and `1e-17`, - respectively. A positive infinite step exits after one iteration with a - finite end and needs no non-finite-step guard. +- Float-counter loops accumulate the counter by repeated double addition. + TradingView's measured loop-end rule admits a counter whose excess beyond + the end, computed as one double subtraction in the loop's direction, is + strictly below `1e-10`. The named value `kTvFloatStepEnd` is emitted only + for float-counter loops. Measured on TradingView at the 0.3 scale (counted + up to a 9.999e-11 shortfall, not counted from 1.0e-10) and at counter + magnitudes of about 1, 1e3, 1e6 and 1e9; no magnitude-independent constant + is claimed; not a constant TradingView publishes. + `0.0 to 0.3 by 0.1` counts four iterations; + `0.0 to 2999.9999999999 by 1000.0` counts three. + A positive infinite step exits after one iteration with a finite end + and needs no non-finite-step guard. - Known loop gaps: a float counter passed to an untyped helper truncates here, while TradingView passes the fractional value (accumulating `twice(x)` from 0.0 to 1.0 by 0.25 gives 2 here and 5 @@ -80,9 +81,8 @@ supported as exact pairs; on the 0.x line they are independent. See the accepts `f(0.0)`. PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call. - Shortfalls inside the measured tolerance - bracket have no pinned TradingView count. Block-local/enclosing-counter - bindings and stops inside `request.security` have no TradingView tape. + Block-local/enclosing-counter bindings and stops inside `request.security` + have no TradingView tape. - A Pine v6 function or method reading a later local without an earlier visible binding is refused during analysis at the read (`PF-E2075`), using the same declaration-order rule as later globals (`PF-E2053`). diff --git a/CLAUDE.md b/CLAUDE.md index df5f99fc..24462a1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -416,15 +416,16 @@ when their result is read in a shape not measured, where TradingView may drop them. Endless loops in functions, methods and `request.security` still never end here while TradingView stops them; data-dependent endless loops still hang. -A float-counter loop accumulates its counter and continues while -`x <= end + kFloatStepTolerance` (the mirrored test when descending), within -an absolute allowance of the end in the units of the counter. -`kFloatStepTolerance = 1e-12` is emitted only when a float-counter loop exists. -It is chosen inside the measured bracket about `[5.6e-17, 9.98e-11]`; -it is not a TradingView constant. Measurements at very large and very small -steps rule out an allowance relative to the step or to the end. +A float-counter loop accumulates its counter by repeated double addition. +TradingView's measured loop-end rule admits a counter whose excess beyond +the end, computed as one double subtraction in the loop's direction, is +strictly below `1e-10`. The named value `kTvFloatStepEnd` is emitted only +when a float-counter loop exists. Measured on TradingView at the 0.3 scale +(counted up to a 9.999e-11 shortfall, not counted from 1.0e-10) and at counter +magnitudes of about 1, 1e3, 1e6 and 1e9; no magnitude-independent constant +is claimed; not a constant TradingView publishes. A positive infinite step leaves the counter beyond the finite bound after -one iteration; the absolute allowance needs no non-finite-step guard. +one iteration; the difference comparison needs no non-finite-step guard. The measured entry-quantity probes in `fixtures/for_counter_headers` include `end_reads_counter` = 1, `end_reads_counter_other` = 3, @@ -443,7 +444,9 @@ zero entries. Non-positive-step probes also measure no-trade stops. The additional inexact-step probes and export hashes are recorded in `tradingview_quantities.json`: `0.0 to 0.3 by 0.1` counts four iterations, while `0.0 to 2999.9999999999 by 1000.0` counts three. These rows reject -absolute allowances of `1e-17` and `1e-10`, respectively. +end comparisons that lose the accumulated last iteration or round the end +up before comparing it. Additional recorded counts exercise the measured +end difference in both directions and at the measured counter magnitudes. Known gaps: a float counter passed to an untyped helper truncates here while TradingView passes the fractional value (`twice(x)` accumulates 2 here versus 5 on TradingView). @@ -454,7 +457,6 @@ For `f(x)` containing `for v = x to 1.0 by 0.25`, TradingView refuses `f(0)` with CE10106 and accepts `f(0.0)`. PineForge accepts and runs both with a float counter because this inference cannot distinguish their call-argument types; refusing the unknown start would also refuse the valid float-argument call. -Shortfalls inside the measured tolerance bracket are not pinned by a measurement. Block-local/enclosing-counter bindings, end bounds that change direction during iteration and stops inside `request.security` have no TradingView tape. Integer-counter controls are guarded diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 0c17028d..9095ab1e 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -307,7 +307,7 @@ class @JOURNAL@ { ) from .constant_fold import fold_numeric_expression from .session_market import SESSION_MARKET_CPP, SESSION_MARKET_MEMBER -from .for_loops import FLOAT_STEP_TOLERANCE_CPP +from .for_loops import TV_FLOAT_STEP_END_CPP # TypeInferer mixin owns the ~15 type-spec / C++-type inference helpers # previously scattered across this module; see ``codegen/types.py``. @@ -793,7 +793,7 @@ def func_var_storage(owner: str, raw_name: str) -> str: self._security_arg_call_values: dict[tuple, str] = {} self._random_call_counter = 0 self._for_counter = 0 - self._uses_float_step_tolerance = False + self._uses_tv_float_step_end = False # Retain each loop node alongside its plan; ids are lookup keys only. self._closed_trade_folds: dict[int, tuple[ForStmt, object]] = {} self._tuple_assign_counter = 0 @@ -5079,7 +5079,7 @@ def _generate_cpp(self) -> str: # 1. Includes self._emit_includes(lines) - float_step_tolerance_at = len(lines) + tv_float_step_end_at = len(lines) if self._array_journal_members: lines.append(graph_journal.runtime(self, ARRAY_JOURNAL_CPP).replace( "@JOURNAL@", self._array_journal_cpp_name)) @@ -6091,8 +6091,8 @@ def allocate_legacy_flag(base: str) -> str: emit_capabilities_exports(self, exports) lines[self._capabilities_export_at:self._capabilities_export_at] = exports # A float counter may first appear in a requested evaluator. - if self._uses_float_step_tolerance: - lines.insert(float_step_tolerance_at, FLOAT_STEP_TOLERANCE_CPP) + if self._uses_tv_float_step_end: + lines.insert(tv_float_step_end_at, TV_FLOAT_STEP_END_CPP) # Function bodies and security evaluators can select the enum lookup. if self._uses_enum_string_lookup: lines.insert(0, ENUM_STRING_INCLUDE) diff --git a/pineforge_codegen/codegen/for_loops.py b/pineforge_codegen/codegen/for_loops.py index dc7f2482..2ae98a5f 100644 --- a/pineforge_codegen/codegen/for_loops.py +++ b/pineforge_codegen/codegen/for_loops.py @@ -1,14 +1,17 @@ -"""The absolute continuation allowance for floating counted loops.""" +"""TradingView's measured continuation rule for floating counted loops.""" -# Absolute allowance in the counter's units, chosen inside the measured -# bracket about [5.6e-17, 9.98e-11]; it is not a TradingView constant. -FLOAT_STEP_TOLERANCE_CPP = "static constexpr double kFloatStepTolerance = 1e-12;\n" +# TradingView's measured loop-end rule: a counter whose difference to the end is +# below 1e-10 is still within the loop. Measured on TradingView at the 0.3 scale +# (counted up to a 9.999e-11 shortfall, not counted from 1.0e-10) and at counter +# magnitudes of about 1, 1e3, 1e6 and 1e9; no magnitude-independent constant is +# claimed; not a constant TradingView publishes. +TV_FLOAT_STEP_END_CPP = "static constexpr double kTvFloatStepEnd = 1e-10;\n" def for_continuation(counter_cpp_type, var, end, step, down) -> str: if counter_cpp_type == "double": return ( - f"({down} ? ({var} >= {end} - ::kFloatStepTolerance)" - f" : ({var} <= {end} + ::kFloatStepTolerance))" + f"({down} ? (({end} - {var}) < ::kTvFloatStepEnd)" + f" : (({var} - {end}) < ::kTvFloatStepEnd))" ) return f"({down} ? ({var} >= {end}) : ({var} <= {end}))" diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index ae2db963..fd4d6a0d 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -1742,11 +1742,11 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int, if action is not None: # Keep lexical/naming bookkeeping (including nested loop tokens) # identical for statements after this one; discard only its text. - saved_float_tolerance = self._uses_float_step_tolerance + saved_tv_float_step_end = self._uses_tv_float_step_end try: self._visit_counter_bound_for(node, [], indent, value_target) finally: - self._uses_float_step_tolerance = saved_float_tolerance + self._uses_tv_float_step_end = saved_tv_float_step_end if action == "stop": pad = " " * indent lines.append( @@ -1834,7 +1834,7 @@ def _visit_for(self, node: ForStmt, lines: list[str], indent: int, def _for_continuation(self, counter_cpp_type, var, end, step, down) -> str: if counter_cpp_type == "double": - self._uses_float_step_tolerance = True + self._uses_tv_float_step_end = True return for_continuation(counter_cpp_type, var, end, step, down) def _for_header_reads_new_counter(self, node: ForStmt) -> bool: diff --git a/tests/fixtures/for_counter_headers/c2_end_rule_tapes.json b/tests/fixtures/for_counter_headers/c2_end_rule_tapes.json new file mode 100644 index 00000000..b2272797 --- /dev/null +++ b/tests/fixtures/for_counter_headers/c2_end_rule_tapes.json @@ -0,0 +1,599 @@ +{ + "provenance": "TradingView counts, unedited, from the sealed TSVs of the pre-registered measurements (counter = acc += 1 once per iteration, qty = acc); measured at the 0.3 scale and at counter magnitudes of about 1, 1e3, 1e6 and 1e9; no magnitude-independent constant is claimed", + "inputs": { + "stage1": { + "counts_tsv_sha256": "dec1d72ad7d4ea147b83667974d1bf8f228bf7448a5ea184ea2507278b27ce20", + "record": "RECORD.md 9c3062922f6124387722c9e2157a829ce49b48bcc245b0133257ea8cc944af71; WIN verdicts d240a4aaf9d6419b48152be5b7db0f2fd65c5997c0c5551e658174cbe719a71e" + }, + "stage1b": { + "counts_tsv_sha256": "c7f1218b2377bf70ea7e3ff868d1c84fde760f4275c8112f6a22126168476d6e", + "record": "RECORD-1b.md 39088ca86ea1adb124bad665df219eddd7b04b5cea2104e9c61b71fd0c330386" + }, + "stage2b": { + "counts_tsv_sha256": "6417bb7902b75982f4dbd9d65c89a2de584d392b31973a460d8cab13907ddd3c", + "record": "RECORD-2b.md ee943471b5045e19b4272a7e19313df4396cd8c5683a10aca11f596b0fddefc1" + }, + "final": { + "counts_tsv_sha256": "7114fec04c2fd3d6229d25b8319d8868debf86ea3c258c579eb0623421dbfebf", + "record": "RECORD-final.md 1123e6448e3cd02a008b99bd83d2dcef7f63be45ebe816cbee080ecc0e2c903e" + } + }, + "tapes": [ + { + "name": "ladder_over_1e-11", + "stage": "stage1", + "start": "0.0", + "end": "0.30000000001", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_over_1e-14", + "stage": "stage1", + "start": "0.0", + "end": "0.30000000000001", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_1e-10", + "stage": "stage1", + "start": "0.0", + "end": "0.2999999999", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "ladder_short_1e-11", + "stage": "stage1", + "start": "0.0", + "end": "0.29999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_1e-12", + "stage": "stage1", + "start": "0.0", + "end": "0.299999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_1e-13", + "stage": "stage1", + "start": "0.0", + "end": "0.2999999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_1e-14", + "stage": "stage1", + "start": "0.0", + "end": "0.29999999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_1e-15", + "stage": "stage1", + "start": "0.0", + "end": "0.299999999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_2e-11", + "stage": "stage1", + "start": "0.0", + "end": "0.29999999998", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_2e-16", + "stage": "stage1", + "start": "0.0", + "end": "0.2999999999999998", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_5e-11", + "stage": "stage1", + "start": "0.0", + "end": "0.29999999995", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_5e-12", + "stage": "stage1", + "start": "0.0", + "end": "0.299999999995", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_5e-13", + "stage": "stage1", + "start": "0.0", + "end": "0.2999999999995", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_5e-14", + "stage": "stage1", + "start": "0.0", + "end": "0.29999999999995", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_5e-15", + "stage": "stage1", + "start": "0.0", + "end": "0.299999999999995", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "ladder_short_5e-16", + "stage": "stage1", + "start": "0.0", + "end": "0.2999999999999995", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mag1e6_over_1e-11", + "stage": "stage1", + "start": "1000000.0", + "end": "1000000.30000000001", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mag1e6_short_1e-10", + "stage": "stage1", + "start": "1000000.0", + "end": "1000000.2999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mag1e6_short_1e-11", + "stage": "stage1", + "start": "1000000.0", + "end": "1000000.29999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mag1e9_over_1e-9", + "stage": "stage1", + "start": "1000000000.0", + "end": "1000000000.300000001", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "mag1e9_short_1e-7", + "stage": "stage1", + "start": "1000000000.0", + "end": "1000000000.2999999", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "mag1e9_short_1e-9", + "stage": "stage1", + "start": "1000000000.0", + "end": "1000000000.299999999", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "nondec_step_007_short_1e-11", + "stage": "stage1", + "start": "0.0", + "end": "0.27999999999", + "step": "0.07", + "tv_count": 5 + }, + { + "name": "nondec_step_007_to_028", + "stage": "stage1", + "start": "0.0", + "end": "0.28", + "step": "0.07", + "tv_count": 5 + }, + { + "name": "nondec_step_015_short_1e-11", + "stage": "stage1", + "start": "0.0", + "end": "0.59999999999", + "step": "0.15", + "tv_count": 5 + }, + { + "name": "nondec_step_015_to_06", + "stage": "stage1", + "start": "0.0", + "end": "0.6", + "step": "0.15", + "tv_count": 5 + }, + { + "name": "nondec_step_045_short_1e-13", + "stage": "stage1", + "start": "0.0", + "end": "1.3499999999999", + "step": "0.45", + "tv_count": 4 + }, + { + "name": "nondec_step_045_to_135", + "stage": "stage1", + "start": "0.0", + "end": "1.35", + "step": "0.45", + "tv_count": 4 + }, + { + "name": "nondec_step_1_over_7", + "stage": "stage1", + "start": "0.0", + "end": "1.0", + "step": "0.142857142857", + "tv_count": 8 + }, + { + "name": "nondec_step_third_to_one", + "stage": "stage1", + "start": "0.0", + "end": "1.0", + "step": "0.333333333333", + "tv_count": 4 + }, + { + "name": "b_desc_end_plus_0", + "stage": "stage1b", + "start": "1.0", + "end": "0.7", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_desc_end_plus_1.1e-10", + "stage": "stage1b", + "start": "1.0", + "end": "0.70000000011", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_desc_end_plus_4e-11", + "stage": "stage1b", + "start": "1.0", + "end": "0.70000000004", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_desc_end_plus_6e-11", + "stage": "stage1b", + "start": "1.0", + "end": "0.70000000006", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_desc_end_plus_8e-11", + "stage": "stage1b", + "start": "1.0", + "end": "0.70000000008", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1000000_short_1.2e-10", + "stage": "stage1b", + "start": "1000000.0", + "end": "1000000.29999999988", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1000000_short_1e-10", + "stage": "stage1b", + "start": "1000000.0", + "end": "1000000.2999999999", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1000000_short_6e-11", + "stage": "stage1b", + "start": "1000000.0", + "end": "1000000.29999999994", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1000000_short_8e-11", + "stage": "stage1b", + "start": "1000000.0", + "end": "1000000.29999999992", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1000_short_1.2e-10", + "stage": "stage1b", + "start": "1000.0", + "end": "1000.29999999988", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_mag1000_short_1e-10", + "stage": "stage1b", + "start": "1000.0", + "end": "1000.2999999999", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_mag1000_short_6e-11", + "stage": "stage1b", + "start": "1000.0", + "end": "1000.29999999994", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1000_short_8e-11", + "stage": "stage1b", + "start": "1000.0", + "end": "1000.29999999992", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1_short_1.2e-10", + "stage": "stage1b", + "start": "1.0", + "end": "1.29999999988", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_mag1_short_1e-10", + "stage": "stage1b", + "start": "1.0", + "end": "1.2999999999", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_mag1_short_6e-11", + "stage": "stage1b", + "start": "1.0", + "end": "1.29999999994", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_mag1_short_8e-11", + "stage": "stage1b", + "start": "1.0", + "end": "1.29999999992", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_1.05e-10", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999895", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_scale03_short_1.1e-10", + "stage": "stage1b", + "start": "0.0", + "end": "0.29999999989", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_scale03_short_1.5e-10", + "stage": "stage1b", + "start": "0.0", + "end": "0.29999999985", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "b_scale03_short_4.5e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999955", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_5.5e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999945", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_6.5e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999935", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_6e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.29999999994", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_7.5e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999925", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_7e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.29999999993", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_8.5e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999915", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_8e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.29999999992", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_9.5e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.299999999905", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "b_scale03_short_9e-11", + "stage": "stage1b", + "start": "0.0", + "end": "0.29999999991", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mini2_1_short_9.6e-11", + "stage": "stage2b", + "start": "0.0", + "end": "0.299999999904", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mini2_2_short_9.7e-11", + "stage": "stage2b", + "start": "0.0", + "end": "0.299999999903", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "mini2_3_short_9.9e-11", + "stage": "stage2b", + "start": "0.0", + "end": "0.299999999901", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_dec_scale03_short_1.00005e-10", + "stage": "final", + "start": "0.0", + "end": "0.299999999899995", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "f_dec_scale03_short_1.0e-10", + "stage": "final", + "start": "0.0", + "end": "0.29999999990", + "step": "0.1", + "tv_count": 3 + }, + { + "name": "f_dec_scale03_short_9.92e-11", + "stage": "final", + "start": "0.0", + "end": "0.2999999999008", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_dec_scale03_short_9.95e-11", + "stage": "final", + "start": "0.0", + "end": "0.2999999999005", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_dec_scale03_short_9.97e-11", + "stage": "final", + "start": "0.0", + "end": "0.2999999999003", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_dec_scale03_short_9.98e-11", + "stage": "final", + "start": "0.0", + "end": "0.2999999999002", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_dec_scale03_short_9.995e-11", + "stage": "final", + "start": "0.0", + "end": "0.29999999990005", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_dec_scale03_short_9.999e-11", + "stage": "final", + "start": "0.0", + "end": "0.29999999990001", + "step": "0.1", + "tv_count": 4 + }, + { + "name": "f_ulp_scale03_T9.99e-11_k+0", + "stage": "final", + "start": "0.0", + "end": "0.29999999990010007", + "step": "0.1", + "tv_count": 4 + } + ] +} diff --git a/tests/fixtures/for_counter_headers/tradingview_quantities.json b/tests/fixtures/for_counter_headers/tradingview_quantities.json index 5c2534bb..410befef 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -10,11 +10,10 @@ "compile_only_zero_entries": ["ta.highest(3)"], "ta.valuewhen": "no receipt, kept as on main" }, - "float_step_tolerance": { - "measured_absolute_bracket": "about [5.6e-17, 9.98e-11] in the units of the counter, from double arithmetic over all 18 measured rows", - "choice": "kFloatStepTolerance = 1e-12 is chosen inside the measured bracket; it is not a TradingView constant", - "rule": "Accumulate the counter; continue within an absolute allowance of the end: x <= end + kFloatStepTolerance ascending, x >= end - kFloatStepTolerance descending", - "measurement": "Measurements at very large and very small steps rule out an allowance relative to the step or to the end. An absolute allowance of 1e-10 fails float_counter_mid_step_short_end; 1e-17 fails float_counter_tenth_step_to_three_tenths and float_counter_fifth_step_to_three_fifths. The large-magnitude row does not constrain the allowance in this bracket." + "float_step_end_rule": { + "rule": "Accumulate the counter by repeated double addition. Continue while its excess beyond the end, computed as one double subtraction in the loop's direction, is strictly below 1e-10.", + "measurement": "TradingView counted shortfalls up to 9.999e-11 at the 0.3 scale and did not count them from 1.0e-10. Also measured at counter magnitudes of about 1, 1e3, 1e6 and 1e9; no magnitude-independent constant is claimed; not a constant TradingView publishes.", + "provenance": "The 72 recorded counts are read from c2_end_rule_tapes.json. Its inputs block preserves the sealed count TSV and measurement record SHA-256 digests. The older entry quantities and evidence below remain unchanged." }, "entries_per_probe": 205, "entry_counts": { diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index a7f4b0a0..49c4877f 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -51,10 +51,9 @@ # a documented discrepancy, retaining TradingView's measured 5 in the quantities file. HELPER_VALUE_GAP = "float_counter_helper_value" MATCHING_MEASURED = tuple(name for name in MEASURED if name != HELPER_VALUE_GAP) -# These existing measured rows pin the absolute bracket about -# [5.6e-17, 9.98e-11]: 1e-17 loses the fourth 0.3-by-0.1 iteration; -# 1e-10 wrongly admits the fourth 2999.9999999999-by-1000.0 iteration. -ABSOLUTE_TOLERANCE_BOUNDARY_PROBES = ( +# These measured rows distinguish accumulated rounding near 0.3 from an +# end shortfall at a larger scale. Both use TradingView's recorded quantities. +END_DIFFERENCE_PROBES = ( "float_counter_tenth_step_to_three_tenths", "float_counter_mid_step_short_end", ) @@ -229,6 +228,12 @@ def runs(run_environment): builds[f"{name}_bound"] = Build(bound) builds[f"{name}_in_security"] = Build(_in_security(pine)) builds[f"{name}_bound_in_security"] = Build(_in_security(bound)) + builds[f"{name}_inputs"] = Build(_with_input_header(pine)) + builds[f"{name}_siblings"] = Build(_with_sibling_loop(pine)) + for position in ("before", "after"): + builds[f"{name}_omitted_{position}"] = Build( + _with_omitted_loop(pine, position), + ) for name in REAL_SCRIPTS: builds[name] = Build((FIXTURES / f"{name}.pine").read_text(encoding="utf-8")) for name, (pine, _) in CONTROLS.items(): @@ -302,7 +307,7 @@ def test_fractional_counter_tape_translates_with_a_double_counter(name): @pytest.mark.parametrize("name", tuple( name for name in MATCHING_MEASURED - if name not in ABSOLUTE_TOLERANCE_BOUNDARY_PROBES + if name not in END_DIFFERENCE_PROBES )) def test_tradingview_entry_quantities(runs, name): _assert_quantities( @@ -310,8 +315,8 @@ def test_tradingview_entry_quantities(runs, name): ) -@pytest.mark.parametrize("name", ABSOLUTE_TOLERANCE_BOUNDARY_PROBES) -def test_tradingview_absolute_tolerance_bracket(runs, name): +@pytest.mark.parametrize("name", END_DIFFERENCE_PROBES) +def test_tradingview_end_differences(runs, name): _assert_quantities( ok(runs, name), "default", TV["entry_quantities"][name], probe=name, ) @@ -331,7 +336,10 @@ def test_documented_untyped_helper_fraction_gap(runs, suffix): @pytest.mark.parametrize("name", FLOAT_CONTINUATION_PROBES) -@pytest.mark.parametrize("suffix", ["_bound", "_in_security", "_bound_in_security"]) +@pytest.mark.parametrize("suffix", [ + "_bound", "_in_security", "_bound_in_security", "_inputs", "_siblings", + "_omitted_before", "_omitted_after", +]) def test_float_continuation_routes_follow_measured_counts(runs, name, suffix): _assert_quantities(ok(runs, f"{name}{suffix}"), "default", TV["entry_quantities"][name]) @@ -508,6 +516,49 @@ def bind(match): ) +def _with_input_header(pine: str) -> str: + def bind(match): + return ( + f'first = input.float({match["start"]}, "First")\n' + f'last = input.float({match["end"]}, "Last")\n' + f'stride = input.float({match["step"]}, "Stride")\n' + f'for {match["counter"]} = first to last by stride' + ) + + result, count = re.subn( + r"(?m)^for (?P\w+) = (?P.+?) to (?P.+?)" + r" by (?P[^\n]+)$", + bind, pine, + ) + assert count == 1 + return result + + +def _with_sibling_loop(pine: str) -> str: + lines = pine.splitlines() + loop = next(i for i, line in enumerate(lines) if line.startswith("for ")) + tail = next(i for i, line in enumerate(lines) if line.startswith("if bar_index")) + # Run the same measured loop twice; average the two counts into one order. + return "\n".join([ + *lines[:tail], *lines[loop:tail], + *(line.replace("qty = acc", "qty = acc / 2") for line in lines[tail:]), + "", + ]) + + +def _with_omitted_loop(pine: str, position: str) -> str: + lines = pine.splitlines() + tail = next(i for i, line in enumerate(lines) if line.startswith("if bar_index")) + at = 2 if position == "before" else tail + return "\n".join([ + *lines[:at], + "float unused = 0.0", + "for unusedIndex = 0.0 to unusedIndex by 0.1", + " unused += 1", + *lines[at:], "", + ]) + + # DERIVED, no TradingView tape: exact binary step 0.25 gives # (1.0 - 0.0) / 0.25 + 1 = 5 iterations, each adding 1.0. _FLOAT_COUNTER_SOURCE = _source(""" @@ -529,8 +580,8 @@ def bind(match): # DERIVED, without a TradingView tape: math.exp lowers to std::exp and # overflows to +inf. A finite bound admits the initial counter once, then -# the absolute allowance leaves end +/- allowance finite and rejects the -# infinite counter after its first increment. +# the difference to the end rejects the infinite counter after its first +# increment. NONFINITE_FLOAT_SOURCES = {} for _direction, _start, _end in (("up", "0.0", "1.0"), ("down", "1.0", "0.0")): _pine = _source(f""" @@ -560,6 +611,56 @@ def test_infinite_float_step_terminates_after_one_iteration(runs, name): _assert_quantities(ok(runs, name), "default", 1) +@pytest.mark.parametrize("tail", ["", "plot(acc)\n"], ids=["omitted", "stopped"]) +@pytest.mark.parametrize("finite_loop_at", [None, "before", "after"]) +def test_removed_float_loops_keep_their_runtime_behavior(tail, finite_loop_at): + compile_env.skip_if_no_engine_lib() + removed = ( + "float acc = 0.0\n" + "for i = 0.0 to i by 0.1\n" + " acc += 1\n" + + tail + ) + finite = ( + "float count = 0.0\n" + "for j = 0.0 to 0.3 by 0.1\n" + " count += 1\n" + "plot(count)\n" + ) + body = ( + finite + removed if finite_loop_at == "before" + else removed + finite if finite_loop_at == "after" + else removed + ) + driver = r''' +#include +#include +#include +int main() { + Bar bar{10.0, 10.0, 10.0, 10.0, 1.0, 60000}; + GeneratedStrategy strategy; + strategy.run(&bar, 1, "1", "1"); + if (@STOPPED@) { + assert(strategy.last_error().find("Loop takes too long") != std::string::npos); + } else { + assert(strategy.last_error().empty()); + } + @FINITE_COUNT@ + std::cout << "matched loop behavior"; +} +''' + finite_count = "" + if finite_loop_at is not None and not tail: + expected = TV["entry_quantities"]["float_counter_tenth_step_to_three_tenths"] + finite_count = f"assert(strategy.count == {expected});" + driver = driver.replace("@STOPPED@", "true" if tail else "false") + driver = driver.replace("@FINITE_COUNT@", finite_count) + pine = '//@version=6\nstrategy("Removed float loop", currency=currency.NONE)\n' + body + assert compile_env.run_emitted_tu( + transpile(pine), driver, opt="-O2", label="removed_float_loop", + ) == "matched loop behavior" + + @pytest.mark.parametrize("context", ["chart", "security"]) @pytest.mark.parametrize("name", tuple(TV["runtime_errors"])) def test_nonpositive_step_stops_without_trades(name, context): diff --git a/tests/test_e2e_for_end_rule.py b/tests/test_e2e_for_end_rule.py new file mode 100644 index 00000000..897e18f2 --- /dev/null +++ b/tests/test_e2e_for_end_rule.py @@ -0,0 +1,57 @@ +"""Native loop entry quantities use the recorded TradingView counts.""" + +import csv +import json +from pathlib import Path + +import pytest + +from tests._e2e import Build, chart_feed_head, execute_all, ok, skip_unless_e2e_env + + +FIXTURES = Path(__file__).parent / "fixtures" / "for_counter_headers" +TAPES = json.loads( + (FIXTURES / "c2_end_rule_tapes.json").read_text(encoding="utf-8") +)["tapes"] +TV = json.loads((FIXTURES / "tradingview_quantities.json").read_text(encoding="utf-8")) +SOURCE = """//@version=6 +strategy("{title}", initial_capital = 1000000, currency = currency.NONE) +float acc = 0.0 +for x = {start} to {end} by {step} + acc += 1 +if bar_index > 6 and bar_index % 7 == 3 + strategy.entry("L", strategy.long, qty = acc) +if bar_index > 6 and bar_index % 7 == 5 + strategy.close("L") +""" + + +@pytest.fixture(scope="module") +def runs(tmp_path_factory): + engine = skip_unless_e2e_env() + base = tmp_path_factory.mktemp("for_end_rule") + feed = chart_feed_head(engine, base, 1443) + builds = { + tape["name"]: Build(SOURCE.format( + title=tape["name"], start=tape["start"], + end=tape["end"], step=tape["step"], + )) + for tape in TAPES + } + return execute_all(engine, feed, base, builds) + + +@pytest.mark.parametrize("tape", TAPES, ids=lambda tape: tape["name"]) +def test_float_loop_end_matches_tradingview(runs, tape): + outcome = ok(runs, tape["name"]) + rows = csv.DictReader(outcome.trades["default"].decode("utf-8-sig").splitlines()) + entries = [row for row in rows if row["Type"].startswith("Entry")] + assert len(entries) == TV["entries_per_probe"] + wrong = [ + (row["Trade #"], row["Qty"]) + for row in entries if float(row["Qty"]) != tape["tv_count"] + ] + assert not wrong, ( + f"{tape['name']}: expected every entry quantity to be " + f"{tape['tv_count']}; got {wrong[:10]}" + ) diff --git a/tests/test_for_counter_headers.py b/tests/test_for_counter_headers.py index 19f681f9..6cdcb78f 100644 --- a/tests/test_for_counter_headers.py +++ b/tests/test_for_counter_headers.py @@ -132,70 +132,10 @@ def test_int_or_unresolved_start_keeps_int_header_slots(name, pine): counter = _counter_pattern(name.split("_", 1)[0], "walkIndex") assert re.search(rf"\bfor \(int {counter} = ", cpp), cpp assert not re.search(rf"\bdouble {counter} = ", cpp), cpp - assert "kFloatStepTolerance" not in cpp for slot in ("start", "end", "step"): assert re.search(rf"\bint \w+(?:_{slot}_\d+|_{slot}) = ", cpp), cpp -@pytest.mark.parametrize("context", ["chart", "security"]) -@pytest.mark.parametrize("counter_bound", [False, True]) -@pytest.mark.parametrize("start,step,cpp_type", [ - ("0.0", "0.1", "double"), - ("1.0", "0.1", "double"), - ("0.0", "math.exp(1000.0)", "double"), - ("1.0", "math.exp(1000.0)", "double"), - ("0", "1", "int"), -]) -def test_only_float_continuations_use_the_absolute_allowance( - context, counter_bound, start, step, cpp_type, -): - if counter_bound: - step = f"(walkIndex >= 0 ? {step} : {step})" - pine = _loop_source( - "float acc = 0.0\n" - f"for walkIndex = {start} to 0.3 by {step}\n" - " acc += 1", - context, - ) - cpp = transpile(pine) - counter = _counter_pattern(context, "walkIndex") - loop = re.search( - rf"\bfor \((?:{cpp_type} {counter} = \w+)?;" - rf"(?P[^;]*\b{counter}\b[^;]*);", - cpp, - ) - assert loop is not None, cpp - condition = loop["condition"] - if cpp_type == "int": - assert "kFloatStepTolerance" not in cpp - assert re.search(r"\? \(\w+ >= \w+\) : \(\w+ <= \w+\)", condition) - return - assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 - literal = re.search(r"kFloatStepTolerance = ([0-9.eE+-]+);", cpp) - assert literal is not None - assert 0 < float(literal[1]) < 1e-9 - assert re.search( - r"\? \(\w+ >= \w+ - ::kFloatStepTolerance\)" - r" : \(\w+ <= \w+ \+ ::kFloatStepTolerance\)", - condition, - ), condition - # Accumulation preserves the measured large-magnitude count. - assert re.search(r"\w+ \+= \(\w+ \? -\w+ : \w+\)", cpp) - - -def test_multiple_float_loops_share_one_tolerance_definition(): - cpp = transpile(_loop_source( - "float acc = 0.0\n" - "for walkIndex = 0.0 to 0.3 by 0.1\n" - " acc += 1\n" - "for otherIndex = 0.0 to 0.6 by 0.2\n" - " acc += 1", - "chart", - )) - assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 - assert cpp.count("::kFloatStepTolerance") == 4 - - # The chart route retains v5's int counter even for a float start. # A v5 library function in a request.security payload is already refused, # so valid source cannot reach that route's v5 counter-type gate. diff --git a/tests/test_for_counter_type_sources.py b/tests/test_for_counter_type_sources.py index 065854b7..aa1afacd 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -259,7 +259,6 @@ def test_requested_expression_helper_division_start_uses_a_double_counter(): assert re.search( rf"\bdouble _sec0_measure_\d+_for_{slot} = ", evaluator, ), evaluator - assert "::kFloatStepTolerance" in evaluator @pytest.mark.parametrize("prefix,start,family", [ diff --git a/tests/test_for_float_step.py b/tests/test_for_float_step.py index 88c42db5..9b1172c2 100644 --- a/tests/test_for_float_step.py +++ b/tests/test_for_float_step.py @@ -285,11 +285,6 @@ def test_division_start_emits_its_value_family_with_default_step(name): assert not re.search(rf"\bfor \({other} i = ", cpp), cpp for slot in ("start", "end", "step"): assert re.search(rf"\b{family} _for_{slot}_\d+ = ", cpp), cpp - if family == "double": - assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 - assert cpp.count("::kFloatStepTolerance") == 2 - else: - assert "kFloatStepTolerance" not in cpp @pytest.mark.parametrize("start,end,step", [ @@ -330,9 +325,7 @@ def test_division_start_without_step_matches_a_float_start(name): assert re.search(rf"\bfor \({family} i = ", cpp), cpp for slot in ("start", "end", "step"): assert re.search(rf"\b{family} _for_{slot}_\d+ = ", cpp), cpp - assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 - assert cpp.count("::kFloatStepTolerance") == 2 - # All other C++ bytes, including the end, step, body and tolerance, + # All other C++ bytes, including the end, step and body, # must match the twin whose Pine source differs only in the start. normalized.append(_normalize_declared_loop_start(cpp, family)) assert normalized[0] == normalized[1] @@ -353,7 +346,6 @@ def test_proved_integral_division_matches_an_int_start(start, twin, tmp_path): assert re.search(r"\bfor \(int i = ", cpp), cpp for slot in ("start", "end", "step"): assert re.search(rf"\bint _for_{slot}_\d+ = ", cpp), cpp - assert "kFloatStepTolerance" not in cpp # Division already emits an na-preserving cast and guards where a # literal does not. Preserve every byte of BOTH frozen outputs. pine = tmp_path / "strategy.pine" @@ -382,8 +374,6 @@ def test_unproved_division_uses_double_headers(prefix, start): assert not re.search(r"\bfor \(int i = ", cpp), cpp for slot in ("start", "end", "step"): assert re.search(rf"\bdouble _for_{slot}_\d+ = ", cpp), cpp - assert cpp.count("static constexpr double kFloatStepTolerance =") == 1 - assert cpp.count("::kFloatStepTolerance") == 2 assert_float_step_refused(loop_step_source(start, prefix=prefix)) @@ -407,7 +397,6 @@ def test_integer_counter_keeps_frozen_cpp(prefix, start, end, tmp_path): assert re.search(r"\bfor \(int i = ", cpp), cpp for slot in ("start", "end", "step"): assert re.search(rf"\bint _for_{slot}_\d+ = ", cpp), cpp - assert "kFloatStepTolerance" not in cpp # Use the loop inventory's immutable reference, without replacing any # stored hash or excluding any emitted text from the digest. reference = reference_codegen(LOOP_CPP_REFERENCE) @@ -510,49 +499,3 @@ def test_top_level_v5_keeps_its_existing_version_diagnostic(source: str) -> None diagnostic, = caught.value.diagnostics assert diagnostic.code != "PF-E2079" assert diagnostic.message == "PineForge supports PineScript v6 only (found //@version=5)." - - -@pytest.mark.parametrize("tail", ["", "plot(acc)\n"], ids=["omitted", "stopped"]) -@pytest.mark.parametrize("finite_loop_at", [None, "before", "after"]) -def test_removed_float_loops_do_not_request_a_tolerance(tail, finite_loop_at): - removed = ( - "float acc = 0.0\n" - "for i = 0.0 to i by 0.1\n" - " acc += 1\n" - + tail - ) - finite = ( - "float count = 0.0\n" - "for j = 0.0 to 0.3 by 0.1\n" - " count += 1\n" - "plot(count)\n" - ) - body = ( - finite + removed if finite_loop_at == "before" - else removed + finite if finite_loop_at == "after" - else removed - ) - cpp = transpile(_script(body)) - assert not re.search(r"\bfor \(double i\b", cpp) - expected_definitions = int(finite_loop_at is not None) - assert cpp.count("static constexpr double kFloatStepTolerance =") == expected_definitions - assert ("Loop takes too long" in cpp) == bool(tail) - - -def test_variable_step_does_not_scale_the_emitted_allowance(): - cpp = transpile(_script( - 'stride = input.float(0.1, "Stride")\n' - 'limit = input.float(0.3, "Limit")\n' - "float acc = 0.0\n" - "for x = 0.0 to limit by stride\n" - " acc += 1\n" - "plot(acc)\n" - )) - loop = re.search(r"\bfor \(double x = \w+; (?P[^;]+);", cpp) - assert loop is not None, cpp - # Each allowance is only the constant: neither step nor end scales it. - assert re.search( - r"\(\w+ \? \(x >= \w+ - ::kFloatStepTolerance\)" - r" : \(x <= \w+ \+ ::kFloatStepTolerance\)\)$", - loop["condition"], - ), loop["condition"]