diff --git a/AGENTS.md b/AGENTS.md index 573c1c82..7d7c1ef5 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, @@ -392,16 +398,36 @@ 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. 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. +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." +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. + +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 difference comparison needs no non-finite-step guard. -The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are +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, @@ -415,19 +441,25 @@ 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; -the endless own-counter bound hangs here pending a follow-up change; 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 +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). `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. +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: @@ -448,6 +480,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 @@ -525,6 +564,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/CHANGELOG.md b/CHANGELOG.md index 81dc75be..a435294c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,21 @@ 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. +- 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. - 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 @@ -32,37 +44,45 @@ 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 - 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. -- 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 - 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 + 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 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 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 - 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. + 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. + 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 f00ae49f..24462a1e 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, @@ -392,16 +398,36 @@ 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. 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. +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." +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. + +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 difference comparison needs no non-finite-step guard. -The 22 measured entry-quantity probes in `fixtures/for_counter_headers` are +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, @@ -415,19 +441,25 @@ 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; -the endless own-counter bound hangs here pending a follow-up change; 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 +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). `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. +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: @@ -448,6 +480,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 @@ -525,6 +564,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/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..9095ab1e 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 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``. @@ -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_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 @@ -5077,6 +5079,7 @@ def _generate_cpp(self) -> str: # 1. Includes self._emit_includes(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)) @@ -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_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/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 8abbb0f5..341f6f0b 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): @@ -1722,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/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/for_loops.py b/pineforge_codegen/codegen/for_loops.py new file mode 100644 index 00000000..2ae98a5f --- /dev/null +++ b/pineforge_codegen/codegen/for_loops.py @@ -0,0 +1,17 @@ +"""TradingView's measured continuation rule for floating counted loops.""" + +# 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} ? (({end} - {var}) < ::kTvFloatStepEnd)" + f" : (({var} - {end}) < ::kTvFloatStepEnd))" + ) + return f"({down} ? ({var} >= {end}) : ({var} <= {end}))" 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/security.py b/pineforge_codegen/codegen/security.py index 8812cc2b..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) @@ -5066,10 +5067,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/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..fd5f3f27 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 @@ -163,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, @@ -2384,6 +2386,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 +2626,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 +2640,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 +2723,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 +2826,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 +3261,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] @@ -3734,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"}) @@ -3866,10 +3865,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 +3997,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 +4047,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/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/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 782eb12c..fd4d6a0d 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, @@ -1737,6 +1738,24 @@ 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. + saved_tv_float_step_end = self._uses_tv_float_step_end + try: + self._visit_counter_bound_for(node, [], indent, value_target) + finally: + self._uses_tv_float_step_end = saved_tv_float_step_end + 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 @@ -1800,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_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: """The loop's own counter wins in end/step, including over outer names.""" return any( @@ -1925,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/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/lexer.py b/pineforge_codegen/lexer.py index e430caa6..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: @@ -415,17 +417,16 @@ 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. + 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. last_token = self.tokens[-1] 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, + last_token.line, last_token.end_col, last_token.end_col + 1, ) # Operator-first line continuation: when a line *begins* with a binary @@ -476,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": @@ -484,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 @@ -494,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/pineforge_codegen/loop_types.py b/pineforge_codegen/loop_types.py index 0d61e024..711fca3a 100644 --- a/pineforge_codegen/loop_types.py +++ b/pineforge_codegen/loop_types.py @@ -3,11 +3,12 @@ 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, ) from .limits import iter_ast_nodes +from .errors import CompileError, Diagnostic, Level, Phase from .symbols import PineType, TypeSpec from . import signatures as sigs @@ -33,7 +34,8 @@ "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", + "max", "min", "percentile_nearest_rank", }), } _ARRAY_ELEMENTS = frozenset({ @@ -79,7 +81,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): @@ -100,6 +106,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) @@ -120,12 +127,32 @@ def declaration(self, node): self.declaration_specs[key] = spec return self.declaration_specs[key] - def counter_spec(self, start, step): - 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): + 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): + check_spec = self.infer(start) + step_spec = self.infer(step) + # 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, + 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.", + )]) + 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 @@ -226,11 +253,8 @@ 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", - "highest", "lowest", "range", "percentile_nearest_rank", + "change", "valuewhen", "median", "mode", "range", }: - 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")) if namespace == "request" and name == "security": return self.infer(argument(2, "expression")) @@ -271,10 +295,72 @@ def array_result(name, spec): return None +class _CounterValueQuery(_CounterQuery): + """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 _INT if self._integer_constant(node) is not None else _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, ): - """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 +368,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..1130c3a9 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: @@ -1722,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() @@ -1747,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..2872c18f 100644 --- a/pineforge_codegen/signatures.py +++ b/pineforge_codegen/signatures.py @@ -35,11 +35,22 @@ 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.""" 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 + default_contract: InputDefaultContract | None = None @property def primary(self) -> FuncSig: @@ -73,8 +84,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 +247,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 +277,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)])) @@ -489,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), @@ -502,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) # ============================================================================ @@ -767,6 +793,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..8974624d 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, @@ -73,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 # --------------------------------------------------------------------------- @@ -1310,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 " @@ -1635,6 +1642,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/_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/_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", } diff --git a/tests/_for_float_step.py b/tests/_for_float_step.py new file mode 100644 index 00000000..13d82266 --- /dev/null +++ b/tests/_for_float_step.py @@ -0,0 +1,78 @@ +"""Loop-step sources and diagnostic assertions shared by the inventories.""" + +import re + +import pytest + +from pineforge_codegen import transpile +from pineforge_codegen.errors import CompileError, Level, Phase + + +DIVISION_STARTS = { + "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, value twin, twin end, counter family. Twins share all +# declarations and the end expression, differing only in the start expression. +# 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", + ), + "fractional-series": ( + "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", "double"), + "integral-constant": ("", "4 / 2", "6", "2", "6", "int"), +} + + +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: + 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/_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/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/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/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/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/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/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/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/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 87df390c..410befef 100644 --- a/tests/fixtures/for_counter_headers/tradingview_quantities.json +++ b/tests/fixtures/for_counter_headers/tradingview_quantities.json @@ -1,6 +1,26 @@ { - "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.", + "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_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": { + "fractional_descending": 205, + "fractional_array_index": 205, + "counter_string": 615 + }, "entry_quantities": { "end_reads_counter": 1, "end_reads_counter_other": 3, @@ -21,11 +41,58 @@ "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 + "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, + "fractional_descending": 4.5, + "fractional_array_index": 120, + "counter_string": 1 + }, + "entry_quantity_patterns": { + "fractional_series": { + "quantities": [10.5, 12], + "counts": [103, 102], + "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}, + "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 +100,36 @@ "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.", + "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", "s1_step_neg_literal": "RE10021: 'step' in loop must be greater than zero", @@ -42,33 +139,60 @@ "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"}, + "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"}, + "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"}, "open_counter_header_end": {"check": "builds and runs"}, "open_counter_header_step": {"check": "builds and runs"} } 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_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_continuation_context.py b/tests/test_continuation_context.py index 76e12864..f5522710 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. + (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", - "3", - # The deeper operand still belongs to 1 + 2, giving 3. + (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", - "7", - # The true branch returns 4 + 3, which is 7. + (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", - "2", - # The false ternary arm is the continued literal 2. + (4, 20), + # fn_ternary_8/error.json: width 8 refuses; this longer colon line ends at 4:20. 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. + (4, 26), + # Hand derivation: width 8 refuses; four spaces + "ready = price > 0 and" end at 4:26. 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. + (3, 33), + # fn_arrow_4/error.json: width 4 refuses; this longer inline body ends at 3:33. id="inline_arithmetic_body", ), pytest.param( @@ -73,13 +65,60 @@ " 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. + (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", + (3, 37), + # Hand derivation: inline method width 8 refuses after its trailing star at 3:37. + 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,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 callable wrap is accepted; only the script's plus ends at 6:10. + # 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 87c8b123..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") """, - (3, 10), # Column zero cannot continue the plus, which ends at column 10. + (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") """, - (3, 10), # Four spaces cannot continue the plus, which ends at column 10. + (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_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), + ] diff --git a/tests/test_continuation_widths.py b/tests/test_continuation_widths.py new file mode 100644 index 00000000..ed8b03b4 --- /dev/null +++ b/tests/test_continuation_widths.py @@ -0,0 +1,181 @@ +"""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", (3, 18)), + "block": ( + "float a = 0.0\nif bar_index % 2 == 0\n a := close +\n", + "open\n", (5, 17), + ), + "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", None, + ), +} + +# 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. + 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, 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) + assert result["ok"] is accepted, result + if accepted: + assert result["cpp"] + else: + (diagnostic,) = result["diagnostics"] + assert diagnostic["severity"] == "error" + assert diagnostic["code"] == "PF-E0038" + # 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_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_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] diff --git a/tests/test_e2e_for_counter_headers.py b/tests/test_e2e_for_counter_headers.py index e5b90d2c..49c4877f 100644 --- a/tests/test_e2e_for_counter_headers.py +++ b/tests/test_e2e_for_counter_headers.py @@ -7,7 +7,9 @@ from __future__ import annotations +from collections import Counter import csv +import hashlib import json from pathlib import Path import re @@ -17,13 +19,44 @@ 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, 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 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"]) +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"), + "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"), + "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. +HELPER_VALUE_GAP = "float_counter_helper_value" +MATCHING_MEASURED = tuple(name for name in MEASURED if name != HELPER_VALUE_GAP) +# 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", +) FEED_BARS = 1443 ZERO_ENTRY_PROBES = tuple(TV["zero_entry_probes"]) FIRST_END_READ_PROBES = ( @@ -32,6 +65,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) @@ -150,14 +192,20 @@ 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, *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") @@ -167,17 +215,36 @@ 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. 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)) + 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(): 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(): + builds[name] = Build(pine) return execute_all(engine, feed, base, builds) @@ -186,7 +253,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 @@ -206,25 +273,78 @@ 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 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 "" + ), (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) + assert re.fullmatch(r"[0-9a-fA-F]{64}", TV["ta_counter_type_receipt"]["sha256"]) + + +@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", MEASURED) +@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 END_DIFFERENCE_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) +@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, + ) + + +@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", "_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]) + + @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. @@ -277,6 +397,84 @@ def test_counter_binding_compositions(runs, name): _assert_quantities(outcome, tag, expected) +@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")] + 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("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.""" + _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''' #include #include @@ -307,6 +505,60 @@ 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, + ) + + +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(""" @@ -326,11 +578,89 @@ 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 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""" + 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("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_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_counter_type_sources.py b/tests/test_for_counter_type_sources.py index 8b0b2df9..aa1afacd 100644 --- a/tests/test_for_counter_type_sources.py +++ b/tests/test_for_counter_type_sources.py @@ -13,10 +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 ( + 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, @@ -135,29 +139,192 @@ 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)") + 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 \(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 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.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 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.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_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) == 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(): + # 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 + + +@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, 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 "'step' in loop must be greater than zero" in error + assert query.infer(loop.start) == TypeSpec.primitive("int") + 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"]) @@ -169,22 +336,53 @@ 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"), + 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", "int", id="division-int-step"), + pytest.param("", "begin = 4 / 2\n", "begin", "1", "int", + id="named-division-int-step"), + # 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 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"), + # 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, ): # 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 +399,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 +415,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_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 + """))) diff --git a/tests/test_for_float_step.py b/tests/test_for_float_step.py new file mode 100644 index 00000000..9b1172c2 --- /dev/null +++ b/tests/test_for_float_step.py @@ -0,0 +1,501 @@ +"""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 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, + 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 +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), + ("", "4 / 2.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("name", sorted(DIVISION_STARTS)) +def test_int_division_start_still_refuses_a_float_step(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_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(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("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)) + + +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, 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), + ] + normalized = [] + for source in sources: + cpp = transpile(source) + 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 + # 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] + + +@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 + # 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_float_step_refused(loop_step_source(start, prefix=prefix)) + + +@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(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 + # 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_its_twins_counter_values(name): + prefix, start, end, twin, twin_end, _family = DIVISION_VALUE_STARTS[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; +} +''' + # 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"]) +def test_untyped_parameter_start_keeps_documented_call_argument_gap(argument): + # 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" + " 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)." diff --git a/tests/test_indentation_refusals_extra.py b/tests/test_indentation_refusals_extra.py index 5e8604c2..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", - 3, 21, # The plus is at column 20, so the line ends at column 21. + 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", - 3, 20, # The four-letter source puts the line end at column 20. + 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", - 3, 20, # high has the same width as open, ending the line at column 20. + 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", - 3, 19, # The three-letter source puts the line end at column 19. + 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", - 3, 19, # hl2 has the same width as low, ending the line at column 19. + 3, 19, # cont_top_8/error.json: width 8 refuses; "hl2" gives a plus end of 3:19. 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", + 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", + 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", + 3, 21, # cont_top_12/error.json: width 12 refuses; this plus line ends at 3:21. + 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_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_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 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): 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"')