From 13d7022af5ebae27ce08a10ad11210a9abf27bbc Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 28 Jun 2026 17:30:02 +0800 Subject: [PATCH 1/4] fix(codegen): resolve 5 compile-errors + int64-correctness bug class Two classes of codegen defects found by verifying 81 scraped TV strategies against the local engine. Compile-errors (strategy failed to build): - visit_stmt.py: push tuple-destructured TA-call elements into their Series member when history-referenced (`[v,dir]=ta.supertrend()`; `dir[1]`) instead of shadowing the member with a scalar local. - visit_expr.py: materialize an inline `ta.*()[n]` result into a per-bar history buffer (was subscripting a freshly-computed scalar). - security.py: request.security with a tuple body now assigns the per-element `_req_sec_{id}_{i}` members instead of an undeclared aggregate `_req_sec_{id}`. - tables.py: paren-wrap array get/set/remove/percentrank subscript indices so a lambda-leading index can't form `[[` (parsed as a C++11 attribute). - helpers.py: escape user identifiers that collide with builtin read-only accessor names (`gross_profit` etc.) so a user var no longer shadows the `gross_profit()` accessor call. int64-correctness (compiled but silently produced 0/wrong trades): - types.py: input.time / input.color are typed int64_t (their getter is get_input_int64); int32 truncated epoch-ms date-window bounds to negative, leaving the window guard permanently false. - emit_top.py + ta.py: a `var int x = na` initializes to na() (INT_MIN sentinel) not na() (NaN->int is UB and defeats is_na()). - types.py + emit_top.py: bare `time`/`bar_index` history series are declared Series (for time) and pushed every bar, so `time[1]`/`bar_index[n]` read real prior-bar values instead of an unfed na sentinel. Corpus: OK 39->49, compile-errors 6->0, no-trades 5->1, 19 perfect. Full codegen test suite: 1311 passed. Golden updated for the intentional array-subscript paren-wrap. Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/codegen/emit_top.py | 38 +++++++++++++++++++++++++ pineforge_codegen/codegen/helpers.py | 27 +++++++++++++++++- pineforge_codegen/codegen/security.py | 31 ++++++++++++++++++++ pineforge_codegen/codegen/ta.py | 5 +++- pineforge_codegen/codegen/tables.py | 8 +++--- pineforge_codegen/codegen/types.py | 16 +++++++++-- pineforge_codegen/codegen/visit_expr.py | 25 ++++++++++++++++ pineforge_codegen/codegen/visit_stmt.py | 15 +++++++++- tests/golden/matrix_eigen_pca.cpp | 2 +- 9 files changed, 157 insertions(+), 10 deletions(-) diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index c9cb7cb..8fc6852 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -187,6 +187,23 @@ def _script_has_input_source(self) -> bool: return True return False + def _typed_na_init(self, cpp_val: str, name: str, ptype) -> str: + """Re-type a bare ``na()`` initializer to match a non-double + member's C++ type. A ``var int x = na`` resolves its RHS to + ``na()`` (a quiet NaN); constructing/pushing that into an int or + bool member is a NaN->int conversion (UB) that yields garbage and defeats + ``is_na()`` (which checks the type sentinel, e.g. INT_MIN). Returns the + value unchanged unless it is exactly ``na()`` and the member type + is non-double.""" + if cpp_val != "na()": + return cpp_val + cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double") + if cpp_type == "int" and self._is_int64_builtin_init(name): + cpp_type = "int64_t" + if cpp_type == "double": + return cpp_val + return f"na<{cpp_type}>()" + def _emit_constructor(self, lines: list[str]) -> None: init_parts: list[str] = [] # TA members with ctor args @@ -226,6 +243,7 @@ def _emit_constructor(self, lines: list[str]) -> None: continue if name not in self.ctx.series_vars: cpp_val = self._resolve_known(init_expr) + cpp_val = self._typed_na_init(cpp_val, name, ptype) if self._is_compile_time_value(cpp_val): init_parts.append(f"{safe}({cpp_val})") # Strategy params that map to engine members @@ -394,6 +412,25 @@ def _emit_on_bar(self, lines: list[str]) -> None: lines.append(f" if (is_first_tick_) _s_{field_name}.push({push_expr});") lines.append(f" else _s_{field_name}.update({push_expr});") + # a1. Push history-referenced scalar bar builtins (time[n], bar_index[n], + # hl2[n], …). They land in ``series_vars`` and are declared as Series + # members (base.py section 6) but — unlike user series vars (pushed at + # their assignment) and bar fields (pushed above) — have no push site, + # so ``[n]`` would read an unfed buffer (the na sentinel) on every bar. + # Push each from its scalar lowering. A builtin whose lowering is a + # self-referential call (e.g. ``time_close`` -> ``time_close()``) is + # skipped — the call would resolve to the shadowing Series member. + from .tables import BAR_BUILTINS + for _bname in sorted(self.ctx.series_vars): + if _bname in self._var_names: + continue + _bexpr = BAR_BUILTINS.get(_bname) + if _bexpr is None or f"{_bname}(" in _bexpr: + continue + _bsafe = self._safe_name(_bname) + lines.append(f" if (is_first_tick_) {_bsafe}.push({_bexpr});") + lines.append(f" else {_bsafe}.update({_bexpr});") + # a2. Push strategy series for svar in sorted(self._strategy_series_vars): member = svar.replace("_strat_", "") @@ -447,6 +484,7 @@ def _emit_on_bar(self, lines: list[str]) -> None: continue if name in self.ctx.series_vars: cpp_val = self._resolve_known(init_expr) + cpp_val = self._typed_na_init(cpp_val, name, ptype) lines.append(f" {safe}.push({cpp_val});") # Also init cloned copies for per-call-site function variants init_emitted: set[str] = set() diff --git a/pineforge_codegen/codegen/helpers.py b/pineforge_codegen/codegen/helpers.py index 6e25c33..46b011f 100644 --- a/pineforge_codegen/codegen/helpers.py +++ b/pineforge_codegen/codegen/helpers.py @@ -33,6 +33,31 @@ } +# Bare C++ identifiers that ``strategy.*`` (and a few other) read-only +# accessors lower to as zero-arg free-function calls — e.g. +# ``strategy.grossprofit`` -> ``gross_profit()`` (see codegen/visit_expr.py +# and codegen/emit_top.py). A user variable emitted with one of these names +# becomes a class member that shadows the engine accessor, so the codegen +# would emit ``gross_profit = gross_profit();`` and clang rejects the call +# ("called object type 'double' is not a function"). Escaping such user +# identifiers in ``_safe_name`` keeps the two namespaces disjoint; the +# accessor call strings are emitted verbatim and never routed through +# ``_safe_name``, so they are unaffected. Keep in sync with the accessor +# lowerings if new bare-call accessors are added. +BUILTIN_ACCESSOR_NAMES = { + "signed_position_size", "position_entry_name", + "count_wintrades", "count_losstrades", "eventrades", + "net_profit", "gross_profit", "gross_loss", + "grossprofit_percent", "grossloss_percent", + "max_contracts_held_all", "max_contracts_held_long", + "max_contracts_held_short", "max_drawdown_percent", "max_runup_percent", + "avg_trade", "avg_trade_percent", "avg_winning_trade", "avg_losing_trade", + "avg_winning_trade_percent", "avg_losing_trade_percent", + "margin_liquidation_price", "open_profit", "current_equity", + "open_trades_capital_held", +} + + class NamingHelper: """Identifier escaping, callee resolution, and a generic AST walker. @@ -56,7 +81,7 @@ def _cpp_string_escape(s: str) -> str: def _safe_name(self, name: str) -> str: """Rename identifiers that collide with C++ reserved words.""" - if name in CPP_RESERVED: + if name in CPP_RESERVED or name in BUILTIN_ACCESSOR_NAMES: return f"_{name}_" return name diff --git a/pineforge_codegen/codegen/security.py b/pineforge_codegen/codegen/security.py index 7a93e5e..5a18c2d 100644 --- a/pineforge_codegen/codegen/security.py +++ b/pineforge_codegen/codegen/security.py @@ -1232,6 +1232,37 @@ def emit_security_ta(indices: list[int]) -> None: self._emit_security_rebinds(sec_id, info, lines, ta_results, indent=2, emitted_lines=lines) emit_security_ta(post_rebind_ta_indices) + returns_tuple = item.get("returns_tuple", False) + tuple_size = item.get("tuple_size", 0) + if ( + returns_tuple + and tuple_size + and tuple_size > 0 + and isinstance(expr_node, TupleLiteral) + ): + # A tuple body destructures into per-element scalar members + # ``_req_sec_{sec_id}_{i}`` (declared in ``base.py`` and reset in + # ``clear_security``). Assign each element individually rather + # than building the whole ``TupleLiteral`` (which lowers to an + # ``std::make_tuple(...)`` against the non-existent aggregate + # member ``_req_sec_{sec_id}``). + for i, el in enumerate(expr_node.elements): + el_cpp = self._build_security_expr( + sec_id, + el, + None, + ta_results, + security_mutable_names=security_mutable_names, + emitted_lines=lines, + ) + lines.append(f" _req_sec_{sec_id}_{i} = {el_cpp};") + for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())): + lines.append( + f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});" + ) + lines.append(" }") + lines.append("") + continue expr_cpp = self._build_security_expr( sec_id, expr_node, diff --git a/pineforge_codegen/codegen/ta.py b/pineforge_codegen/codegen/ta.py index a8da6bd..00a6858 100644 --- a/pineforge_codegen/codegen/ta.py +++ b/pineforge_codegen/codegen/ta.py @@ -295,4 +295,7 @@ def _is_compile_time_value(val: str) -> bool: return True except ValueError: pass - return val in ("true", "false", "0", "0.0", "na()") + return val in ( + "true", "false", "0", "0.0", + "na()", "na()", "na()", "na()", + ) diff --git a/pineforge_codegen/codegen/tables.py b/pineforge_codegen/codegen/tables.py index 0955330..74300fb 100644 --- a/pineforge_codegen/codegen/tables.py +++ b/pineforge_codegen/codegen/tables.py @@ -382,14 +382,14 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: # Methods called as ``array.method(arr, ...)`` or ``arr.method(...)``. ARRAY_METHODS = { - "get": lambda a, args: f"{a}[{args[0]}]", - "set": lambda a, args: f"{a}[{args[0]}] = {args[1]}", + "get": lambda a, args: f"{a}[({args[0]})]", + "set": lambda a, args: f"{a}[({args[0]})] = {args[1]}", "push": lambda a, args: f"{a}.push_back({args[0]})", "unshift": lambda a, args: f"{a}.insert({a}.begin(), {args[0]})", "insert": lambda a, args: f"{a}.insert({a}.begin() + (int)({args[0]}), {args[1]})", "pop": lambda a, args: f"[&](){{ auto v={a}.back(); {a}.pop_back(); return v; }}()", "shift": lambda a, args: f"[&](){{ auto v={a}.front(); {a}.erase({a}.begin()); return v; }}()", - "remove": lambda a, args: f"[&](){{ auto v={a}[{args[0]}]; {a}.erase({a}.begin()+(int)({args[0]})); return v; }}()", + "remove": lambda a, args: f"[&](){{ auto v={a}[({args[0]})]; {a}.erase({a}.begin()+(int)({args[0]})); return v; }}()", "first": lambda a, args: f"{a}.front()", "last": lambda a, args: f"{a}.back()", "size": lambda a, args: f"(double){a}.size()", @@ -435,7 +435,7 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str: "mode": lambda a, args: f"[&](){{ std::unordered_map m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v=(int)c.size()) return c.back(); return c[i]*(1-f)+c[i+1]*f; }}()", "percentile_nearest_rank": lambda a, args: f"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); int r=(int)std::ceil(({args[0]}/100.0)*c.size()); return c[std::min(r-1,(int)c.size()-1)]; }}()", - "percentrank": lambda a, args: f"[&](){{ if({a}.size()<=1) return na(); double v={a}[{args[0]}]; if(std::isnan(v)) return na(); int le=0; for(auto x:{a}) if(!std::isnan(x) && x<=v) le++; return (double)(le-1)/({a}.size()-1)*100.0; }}()", + "percentrank": lambda a, args: f"[&](){{ if({a}.size()<=1) return na(); double v={a}[({args[0]})]; if(std::isnan(v)) return na(); int le=0; for(auto x:{a}) if(!std::isnan(x) && x<=v) le++; return (double)(le-1)/({a}.size()-1)*100.0; }}()", "abs": lambda a, args: f"[&](){{ std::vector r; for(auto v:{a})r.push_back(std::abs(v)); return r; }}()", "join": lambda a, args: "[&](){{ std::string r; for(size_t i=0;i<{arr}.size();i++){{ if(i>0)r+={sep}; r+=std::to_string({arr}[i]); }} return r; }}()".format(arr=a, sep=args[0] if args else 'std::string(",")'), "standardize": lambda a, args: f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); s=std::sqrt(s/{a}.size()); std::vector r; for(auto v:{a})r.push_back(s==0?1.0:(v-m)/s); return r; }}()", diff --git a/pineforge_codegen/codegen/types.py b/pineforge_codegen/codegen/types.py index c2fe550..5883bc3 100644 --- a/pineforge_codegen/codegen/types.py +++ b/pineforge_codegen/codegen/types.py @@ -357,7 +357,12 @@ def _type_for_decl(self, node: VarDecl) -> str: def _series_type_for(self, name: str) -> str: """C++ element type for a series variable's history buffer.""" - if self._is_int64_builtin_init(name): + from .tables import INT64_BUILTINS + # A bare int64 bar builtin used as a history series (``time[1]``) needs an + # int64_t buffer: epoch-ms overflow int32 and the na sentinel would be + # misdetected. ``_is_int64_builtin_init`` only matches user vars whose + # init RHS is such a builtin, so also match the builtin name directly. + if name in INT64_BUILTINS or self._is_int64_builtin_init(name): return "int64_t" sym = self.ctx.symbols.resolve(name) if sym is not None: @@ -473,8 +478,15 @@ def _infer_type(self, node) -> str: return "std::string" if func_name == "bool": return "bool" - if func_name in ("int", "color", "time"): + if func_name == "int": return "int" + # ``input.time`` returns an epoch-MS timestamp and ``input.color`` + # a packed ARGB int — both use the ``get_input_int64`` getter + # (input.py), so their storage must be ``int64_t`` or the value + # truncates under int32 (e.g. a date-window bound flips sign and + # the guard is permanently false). + if func_name in ("color", "time"): + return "int64_t" return "double" if namespace == "str": if func_name == "split": diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index 494c3b8..f0ec391 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -717,6 +717,31 @@ def _visit_subscript(self, node: Subscript) -> str: if series_name not in self._strategy_series_vars: self._strategy_series_vars.add(series_name) return f"{series_name}[{idx}]" + # History reference applied directly to an inline call result, e.g. + # ``ta.highest(high, 10)[1]`` or ``f()[2]``. In Pine the call yields a + # series, so ``[k]`` reads its value k bars ago — but the call lowers to + # a freshly-computed C++ scalar, and ``scalar[k]`` is not subscriptable. + # Materialize the result into a self-contained history buffer: a static + # ``Series`` that pushes (new bar) / updates (intrabar) the value + # exactly once per evaluation — same semantics as every other series in + # the strategy — and read ``[k]`` off it. The inner call is emitted once + # so its own stateful indicator is not double-stepped, and the buffer + # clears itself on run-start (``is_first_tick_ && bar_index_ == 0``) so a + # reused strategy handle (parameter sweep) does not leak prior-run history. + if isinstance(node.object, FuncCall): + inner = self._visit_expr(node.object) + cpp_t = self._infer_type(node.object) + if cpp_t not in ("double", "int", "bool"): + cpp_t = "double" + return ( + f"([&]() -> {cpp_t} {{ " + f"static thread_local Series<{cpp_t}> _hist_call; " + f"if (is_first_tick_ && bar_index_ == 0) _hist_call.clear(); " + f"{cpp_t} _hv = ({inner}); " + f"if (is_first_tick_) _hist_call.push(_hv); " + f"else _hist_call.update(_hv); " + f"return _hist_call[(int)({idx})]; }}())" + ) obj = self._visit_expr(node.object) # If subscripting a non-series variable (e.g., function parameter), # src[0] → src (current value), src[N>0] → src (can't access history) diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 1e22e59..5558ff0 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -544,7 +544,20 @@ def _visit_tuple_assign(self, node: TupleAssign, lines: list[str], pad: str) -> if name == "_": continue if i < len(fields): - lines.append(f"{pad}double {name} = {result_var}.{fields[i]};") + field_expr = f"{result_var}.{fields[i]}" + # A history-referenced destructured name (e.g. + # ``[v, dir] = ta.supertrend(...)`` with ``dir[1]`` used + # later) is tracked in ``series_vars`` and declared as a + # ``Series`` class member. Pushing into that member keeps + # its history buffer advancing so ``dir[n]`` resolves; a + # fresh ``double`` local would shadow the member and make + # ``dir[n]`` a scalar subscript (clang error). Non-series + # destructured names keep the plain scalar declaration. + if name in self.ctx.series_vars: + safe = self._safe_name(name) + lines.append(f"{pad}{safe}.push({field_expr});") + else: + lines.append(f"{pad}double {name} = {field_expr};") return # User-defined function returning a tuple: use C++17 structured bindings diff --git a/tests/golden/matrix_eigen_pca.cpp b/tests/golden/matrix_eigen_pca.cpp index 656fd30..b7ab028 100644 --- a/tests/golden/matrix_eigen_pca.cpp +++ b/tests/golden/matrix_eigen_pca.cpp @@ -189,7 +189,7 @@ class GeneratedStrategy : public BacktestEngine { covReady = ((!(is_na(cov11)) && !(is_na(cov12))) && !(is_na(cov22))); lam = na(); if (covReady) { - lam = ((((double)m.eigenvalues().size() > 0)) ? (m.eigenvalues()[0]) : (na())); + lam = ((((double)m.eigenvalues().size() > 0)) ? (m.eigenvalues()[(0)]) : (na())); } lamSma = (is_first_tick_ ? _ta_sma_6.compute(lam) : _ta_sma_6.recompute(lam)); if ((((covReady && !(is_na(lam))) && !(is_na(lamSma))) && (is_first_tick_ ? _ta_crossover_7.compute(lam, lamSma) : _ta_crossover_7.recompute(lam, lamSma)))) { From 92a6cbe36299c576f928c8ca79ea45267a68d57b Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 28 Jun 2026 20:34:25 +0800 Subject: [PATCH 2/4] feat(codegen): coverage (visual no-ops, unknown-var, lookahead) + multi-line lexer fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applicability + one mismatch resolved, all guarded by the 1312-test suite. Coverage (strategies a batch backtest should accept, not reject): - support_checker.py: cosmetic/chart-only calls are WARNINGS, not hard errors — color.from_gradient, bare color() cast, chart.fg_color/bg_color/ *_visible_bar_time. Codegen emits a benign default (0 / na color) so the C++ still compiles. A backtest must not be thrown out for drawing or theming. - support_checker.py: stop hard-rejecting request.security(..., lookahead= barmerge.lookahead_on) — codegen + engine already implement it end-to-end; only the strict gate rejected it. - lexer.py / parser.py: fix spurious "Unknown variable" rejections of valid Pine — operator-first line continuation (`: na`, `? x`, `+ y`) and `var T[] name` postfix-array declarations were mis-tokenized. Mismatch resolved: - lexer.py: a paren-close that ends with a trailing binary operator (`/`, `+`, …) no longer force-terminates the logical line. A multi-line `avgPrice = (a*x + b*y) / (x + y)` was being split, miscomputing the DCA average-price and thus the take-profit level. dr-ziuber-bb-short-dca: 21% -> 100% trade match (px 100%). Re-transpiled 260 corpus strategies: byte-identical C++ (zero parity impact). Scraped corpus: transpile-errors 23->16, ok 49->53, excellent 31->32. Tests: 1312 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/codegen/visit_call.py | 7 ++ pineforge_codegen/codegen/visit_expr.py | 13 ++- pineforge_codegen/lexer.py | 71 ++++++++++++- pineforge_codegen/parser.py | 10 ++ pineforge_codegen/support_checker.py | 100 ++++++++++++++---- tests/test_official_surface.py | 14 +-- tests/test_support_checker.py | 20 ++-- ..._support_checker_chart_visible_bar_time.py | 9 +- tests/test_support_checker_color_cast.py | 9 +- .../test_support_checker_const_namespaces.py | 18 ++-- 10 files changed, 220 insertions(+), 51 deletions(-) diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index d45f33e..050ded3 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -439,6 +439,13 @@ def _visit_func_call(self, node: FuncCall) -> str: if namespace == "color": return self._visit_color_call(func_name, node) + # Bare color(...) cast (cosmetic). The engine has no color-cast helper + # and colors have no backtest-logic effect, so emit a benign default + # color (0 = na color, matching the color.new / from_gradient + # fallbacks). The support checker warns on this construct. + if namespace is None and func_name == "color" and func_name not in self._func_names: + return "0" + # Skip visual/unsupported namespace calls if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES: return "0" diff --git a/pineforge_codegen/codegen/visit_expr.py b/pineforge_codegen/codegen/visit_expr.py index f0ec391..706f476 100644 --- a/pineforge_codegen/codegen/visit_expr.py +++ b/pineforge_codegen/codegen/visit_expr.py @@ -475,8 +475,17 @@ def _visit_member_access(self, node: MemberAccess) -> str: if node.member in ("is_heikinashi", "is_kagi", "is_linebreak", "is_pnf", "is_range", "is_renko"): return "false" - # Defensive: support_checker.UNSUPPORTED_MEMBERS should already have - # rejected any unhandled chart.* member. Reaching here is a bug. + # Cosmetic / chart-only reads with no batch-mode value (theme + # colors, viewport bar times). The support checker warns on + # these (COSMETIC_MEMBERS); emit a benign default (0 = na color / + # epoch 0). They have no backtest-logic effect. + if node.member in ("fg_color", "bg_color", + "left_visible_bar_time", + "right_visible_bar_time"): + return "0" + # Defensive: support_checker (COSMETIC_MEMBERS / UNSUPPORTED_MEMBERS) + # should already have warned/rejected any unhandled chart.* member. + # Reaching here is a bug. raise ValueError( f"codegen: unhandled chart.{node.member} — analyzer should have rejected. " f"Add a handler above or extend UNSUPPORTED_MEMBERS." diff --git a/pineforge_codegen/lexer.py b/pineforge_codegen/lexer.py index 2cb949f..5e40dad 100644 --- a/pineforge_codegen/lexer.py +++ b/pineforge_codegen/lexer.py @@ -273,9 +273,16 @@ def _tokenize_line(self) -> None: self._read_token() if not self._at_end() and self.source[self.pos] == "\n": self._advance() - # If parens closed on this line, emit NEWLINE so parser sees end of statement + # 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: - self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col) + 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: + self._in_continuation = False + self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col) return # Indentation handling @@ -290,9 +297,24 @@ def _tokenize_line(self) -> None: else: indent_level = len(raw) // 4 + # Operator-first line continuation: when a line *begins* with a binary + # / ternary operator that cannot start a statement (e.g. ``? x``, + # ``: y``, ``+ z``, ``and w``), it continues the previous logical line + # even though that line did not *end* with an operator (the break was + # placed before the operator instead of after it). Suppress this line's + # INDENT/DEDENT and drop the NEWLINE that ended the prior line so the + # parser sees one contiguous expression. Only applies outside parens + # and when not already in an end-of-line continuation. + starts_with_cont_op = ( + not self._in_continuation + and self._line_starts_with_continuation_op() + ) + if starts_with_cont_op and self.tokens and self.tokens[-1].type == TokenType.NEWLINE: + self.tokens.pop() + # If we're in a continuation (previous line ended with an operator), # suppress INDENT/DEDENT — the indentation is cosmetic, not structural - if not self._in_continuation: + if not self._in_continuation and not starts_with_cont_op: current_indent = self.indent_stack[-1] if indent_level > current_indent: self.indent_stack.append(indent_level) @@ -329,6 +351,49 @@ def _tokenize_line(self) -> None: else: self._in_continuation = False + def _line_starts_with_continuation_op(self) -> bool: + """True when the upcoming line content begins with a binary/ternary + operator that can never begin a statement, so the line is a + continuation of the previous logical line. + + Called with ``self.pos`` positioned at the first non-whitespace + character of the line. Deliberately conservative: ``-`` is excluded + (ambiguous leading unary minus) and ``.`` is excluded (``.5`` is a + leading-dot number, not member access). The included operators + (``? : + * / % == != > < >= <= and or``) cannot legally start a Pine + statement, so suppressing the line break for them never merges two + independent statements that previously parsed.""" + src = self.source + p = self.pos + n = len(src) + if p >= n: + return False + c = src[p] + c2 = src[p + 1] if p + 1 < n else "" + # Two-character comparison operators. + if c2 == "=" and c in ("=", "!", ">", "<"): + return True + # ':' ternary-else continuation, but not ':=' (reassignment). + if c == ":" and c2 != "=": + return True + # Single-character operators that cannot start a statement. + if c in "?+*%><": + return True + # '/' division continuation, but never '//' (comment). + if c == "/" and c2 != "/": + return True + # 'and' / 'or' keyword continuation (require a word boundary so names + # like ``android`` / ``organic`` are not misread). + def _kw(word: str) -> bool: + end = p + len(word) + if src[p:end] != word: + return False + nxt = src[end] if end < n else "" + return not (nxt.isalnum() or nxt == "_") + if _kw("and") or _kw("or"): + return True + return False + def _advance_to(self, target: int) -> None: while self.pos < target and self.pos < len(self.source): self._advance() diff --git a/pineforge_codegen/parser.py b/pineforge_codegen/parser.py index 3ddf301..bf8600f 100644 --- a/pineforge_codegen/parser.py +++ b/pineforge_codegen/parser.py @@ -501,6 +501,16 @@ def _parse_var_keyword_decl(self) -> VarDecl: and self._current().value not in ("na",)): # Complex type: array, table, etc. type_hint = self._parse_type_hint_string() + elif (self._current().type == TokenType.IDENT + and self._peek().type == TokenType.LBRACKET + and self._peek(2).type == TokenType.RBRACKET): + # Postfix-array of a non-primitive / UDT element type, e.g. + # ``var line[] lines = ...`` or ``var store[] xs = ...``. The + # empty ``[]`` can only form a type here (a subscript index would + # be non-empty), so this is unambiguously ``array``. Without + # this branch the ``[]`` is left unconsumed, the name fails to + # parse, and the whole declaration is silently dropped. + type_hint = self._parse_type_hint_string() name_tok = self._consume(TokenType.IDENT) self._consume(TokenType.EQUALS) diff --git a/pineforge_codegen/support_checker.py b/pineforge_codegen/support_checker.py index 8d6e777..fc4b869 100644 --- a/pineforge_codegen/support_checker.py +++ b/pineforge_codegen/support_checker.py @@ -80,6 +80,10 @@ SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP) SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP) SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"}) +# Cosmetic color builders with no backtest-logic effect. Warned (not rejected); +# codegen emits a benign default color (0 = na color). color.from_gradient is a +# charting/plot helper that only tints visual output. +COSMETIC_COLOR_FUNC: frozenset[str] = frozenset({"from_gradient"}) SUPPORTED_TIMEFRAME_FUNC: frozenset[str] = frozenset({"change", "in_seconds"}) SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"}) # log.* helpers wired into pine_log_{info,warning,error} by codegen/visit_call. @@ -96,7 +100,6 @@ "request.seed": "External seed data feeds not available in PineForge.", "request.quandl": "External Quandl data not available in PineForge.", "request.currency_rate": "Currency conversion data not available in PineForge.", - "color.from_gradient": "Charting helpers not available in PineForge backtests.", # ticker.* chart-type modifiers and cross-symbol constructors — hard reject "ticker.heikinashi": ( "ticker.heikinashi() chart-type modifier / cross-symbol construction not supported — " @@ -215,10 +218,17 @@ } # Bare (no-namespace) function names that codegen has no handler for. -# Without a handler, the generic emitter at visit_call.py:912 would -# produce e.g. `color(arg)` — an undeclared C++ symbol. Reject loudly. -UNSUPPORTED_BARE_FUNCS: dict[str, str] = { - "color": "Bare color(...) cast is not supported. Use color.new(c, alpha) or color.rgb(r, g, b, transp).", +# Without a handler, the generic emitter at visit_call.py:912 would produce an +# undeclared C++ symbol. Reject loudly. (Currently empty — the bare color(...) +# cast moved to COSMETIC_BARE_FUNCS below.) +UNSUPPORTED_BARE_FUNCS: dict[str, str] = {} + +# Bare (no-namespace) cosmetic casts with no backtest-logic effect. Warned +# (not rejected); codegen emits a benign default. ``color(x)`` is a Pine color +# cast — colors never affect trade logic, so codegen emits a default color +# (0 = na color) and the strategy keeps running. +COSMETIC_BARE_FUNCS: dict[str, str] = { + "color": "color(...) cast has no effect in PineForge backtests (visual only); it emits a default color.", } # Whole namespaces with NO codegen support. Any call into one of these @@ -230,14 +240,19 @@ "volume_row": "volume_row.* is not supported in PineForge batch backtests; same reason as footprint.*.", } -# Member-access references with no batch-mode equivalent. Codegen would -# silently emit "false" (visit_expr.py chart.* fallthrough) which -# becomes epoch 0 in time arithmetic. Reject loudly. -UNSUPPORTED_MEMBERS: dict[tuple[str, str], str] = { - ("chart", "left_visible_bar_time"): "chart.left_visible_bar_time has no meaning in a batch backtest (no viewport).", - ("chart", "right_visible_bar_time"): "chart.right_visible_bar_time has no meaning in a batch backtest (no viewport).", - ("chart", "bg_color"): "chart.bg_color has no meaning in a batch backtest (no chart theme).", - ("chart", "fg_color"): "chart.fg_color has no meaning in a batch backtest (no chart theme).", +# Member-access references with no batch-mode equivalent. Reject loudly. +# (Currently empty — the chart.* cosmetic reads moved to COSMETIC_MEMBERS.) +UNSUPPORTED_MEMBERS: dict[tuple[str, str], str] = {} + +# Cosmetic / chart-only member reads with no backtest-logic effect. Warned +# (not rejected); codegen emits the benign default — chart.* falls through +# SKIP_NAMESPACES in visit_expr.py to ``0`` (and the analyzer types chart.* as +# COLOR, so the 0 lands in an int color slot and compiles cleanly). +COSMETIC_MEMBERS: dict[tuple[str, str], str] = { + ("chart", "left_visible_bar_time"): "chart.left_visible_bar_time has no meaning in a batch backtest (no viewport); emits 0.", + ("chart", "right_visible_bar_time"): "chart.right_visible_bar_time has no meaning in a batch backtest (no viewport); emits 0.", + ("chart", "bg_color"): "chart.bg_color has no meaning in a batch backtest (no chart theme); emits a default color.", + ("chart", "fg_color"): "chart.fg_color has no meaning in a batch backtest (no chart theme); emits a default color.", } # Constant-only namespaces whose members are drawing/visual style constants @@ -692,9 +707,15 @@ def _visit_FuncCall(self, node: FuncCall) -> None: self._visit_children(node) return - # Bare-function rejections (e.g. `color(arg)` cast). Codegen would - # otherwise fall through to the generic emit at visit_call.py:912 and - # produce an undeclared C++ symbol. + # Bare cosmetic casts (e.g. `color(na)` -> default color). No backtest + # effect; warn and let codegen emit a benign default color. + if ns is None and name in COSMETIC_BARE_FUNCS: + self._warn(node, COSMETIC_BARE_FUNCS[name]) + self._visit_children(node) + return + + # Bare-function rejections. Codegen would otherwise fall through to the + # generic emit at visit_call.py:912 and produce an undeclared C++ symbol. if ns is None and self._reject_if_in( UNSUPPORTED_BARE_FUNCS, name, @@ -865,6 +886,16 @@ def _visit_FuncCall(self, node: FuncCall) -> None: self._err(node, f"timeframe.{name}(...) is not implemented in PineForge runtime.") self._visit_children(node) return + if ns == "color" and name in COSMETIC_COLOR_FUNC: + # Cosmetic color builder (e.g. color.from_gradient): no backtest + # effect. Warn and let codegen emit a default color (0). + self._warn( + node, + f"color.{name}(...) has no effect in PineForge backtests " + f"(visual only); it emits a default color.", + ) + self._visit_children(node) + return if ns == "color" and name not in SUPPORTED_COLOR_FUNC: self._err(node, f"color.{name}(...) is not implemented in PineForge runtime.") self._visit_children(node) @@ -1001,7 +1032,21 @@ def _visit_MemberAccess(self, node: MemberAccess) -> None: lambda k, v: f"{k}.{node.member}: {v}", ): return - # Specific unsupported (namespace, member) pairs (e.g. chart.left_visible_bar_time). + # Cosmetic / chart-only member reads (chart.fg_color/bg_color/visible + # bar times). No backtest-logic effect; warn and let codegen emit the + # benign default (chart.* -> 0 via SKIP_NAMESPACES in visit_expr.py). + if ( + isinstance(node.object, Identifier) + and (node.object.name, node.member) in COSMETIC_MEMBERS + ): + self._warn( + node, + f"{node.object.name}.{node.member}: " + f"{COSMETIC_MEMBERS[(node.object.name, node.member)]}", + ) + self._visit_children(node) + return + # Specific unsupported (namespace, member) pairs. if isinstance(node.object, Identifier) and self._reject_if_in( UNSUPPORTED_MEMBERS, (node.object.name, node.member), @@ -1134,10 +1179,25 @@ def _check_request_security(self, node: FuncCall) -> None: hint="Codegen only recognizes the barmerge.lookahead_* literal; other values are silently treated as lookahead_off.", ) if self._is_barmerge_member(lookahead_node, "lookahead_on"): - self._err( + # lookahead_on is supported by codegen + engine: base.py forwards + # the flag into _security_eval_info, emit_top.py registers it via + # register_security_eval(..., lookahead_on=true), and the engine + # (engine_security.cpp) dispatches the partial HTF eval and gates + # the per-bucket series slot on it. The completed-HTF value becomes + # visible from the HTF bucket's first chart bar (TV's forward-look). + # We emit a WARNING (not ERROR) because lookahead_on is inherently + # data-sensitive: with a 0-offset HTF expression it exposes the + # in-progress bucket's value (TV's documented repaint), whereas the + # safe non-repainting idiom pairs it with a [1] offset + # (e.g. close[1]) to read only completed prior buckets. + self._warn( lookahead_node, - "request.security lookahead_on is not supported in PineForge paid parity mode.", - hint="Use barmerge.lookahead_off. lookahead_on can expose future/partial HTF values and is highly data-sensitive.", + "request.security lookahead_on changes HTF timing: the completed " + "higher-timeframe value is exposed from the bucket's first chart " + "bar. PineForge emits this (engine-supported) but results differ " + "from lookahead_off and, with a 0-offset HTF expression, repaint " + "future data.", + hint="The safe non-repainting form pairs lookahead_on with a [1] offset (e.g. close[1]) to read only completed prior HTF bars.", ) # Data-adjustment kwargs: codegen emits a numeric constant but the diff --git a/tests/test_official_surface.py b/tests/test_official_surface.py index 2829387..df6e54d 100644 --- a/tests/test_official_surface.py +++ b/tests/test_official_surface.py @@ -179,8 +179,9 @@ def _pine(body: str) -> str: # that the engine does not currently expose; both layers omit it. KNOWN_TIMEFRAME_OMISSIONS = frozenset({"from_seconds"}) -# color.from_gradient is a charting helper; PineForge has no plotter so -# it is hard-rejected (see HARD_REJECT_FUNC). +# color.from_gradient is a charting/plot helper; PineForge has no plotter so +# it is a cosmetic no-op (warned via COSMETIC_COLOR_FUNC) — not in +# SUPPORTED_COLOR_FUNC, hence still an "omission" from the supported set. KNOWN_COLOR_OMISSIONS = frozenset({"from_gradient"}) @@ -244,8 +245,9 @@ def test_supported_color_matches_official_minus_from_gradient(): f"missing: {sorted(expected - SUPPORTED_COLOR_FUNC)}, " f"extra: {sorted(SUPPORTED_COLOR_FUNC - expected)}" ) - assert "color.from_gradient" in HARD_REJECT_FUNC, ( - "color.from_gradient must remain in HARD_REJECT_FUNC with a clear hint." + assert "color.from_gradient" not in HARD_REJECT_FUNC, ( + "color.from_gradient is now a cosmetic no-op (warned via " + "COSMETIC_COLOR_FUNC), not a hard reject." ) @@ -370,8 +372,6 @@ def test_namespace_smoke_round_trips_through_transpile(label): # Neither side of trade accessors has 'direction' in Pine v6. 'x = strategy.closedtrades.direction(0)', 'x = strategy.opentrades.direction(0)', - # color.from_gradient is a charting helper. - 'x = color.from_gradient(50.0, 0.0, 100.0, color.red, color.green)', # external request feeds. 'x = request.financial(syminfo.tickerid, "TOTAL_REVENUE", "FQ")', 'x = request.dividends(syminfo.tickerid, dividends.gross)', @@ -443,7 +443,7 @@ def test_no_max_bars_back_leaves_series_at_engine_default(): EXPECTED_HARD_REJECT_FUNCS = { "request.financial", "request.dividends", "request.earnings", "request.splits", "request.seed", "request.quandl", - "request.currency_rate", "color.from_gradient", + "request.currency_rate", } diff --git a/tests/test_support_checker.py b/tests/test_support_checker.py index a9e2a83..5610f85 100644 --- a/tests/test_support_checker.py +++ b/tests/test_support_checker.py @@ -160,9 +160,12 @@ def test_unknown_request_function_rejected(): _expect_error(src, "Only request.security") -def test_color_from_gradient_rejected(): +def test_color_from_gradient_warns_not_rejected(): + # Cosmetic charting helper: no backtest-logic effect. Warned (no-op), + # not rejected; codegen emits a default color. src = PRELUDE + "c = color.from_gradient(close, 0, 100, color.red, color.green)\n" - _expect_error(src, "color.from_gradient") + assert not _errors(src) + assert any("from_gradient" in w.message for w in _warnings(src)) def test_unknown_color_function_rejected(): @@ -282,18 +285,23 @@ def test_request_security_lookahead_off_kwarg_passes(): assert _errors(src) == [] -def test_request_security_lookahead_on_kwarg_rejected(): +def test_request_security_lookahead_on_kwarg_warns(): + # lookahead_on is engine-supported (base.py forwards the flag, emit_top.py + # registers it, engine_security.cpp dispatches the partial HTF eval). It is + # allowed but flagged as a data-sensitive parity warning, not rejected. src = (PRELUDE + 'a = request.security(syminfo.tickerid, "60", close, ' 'lookahead=barmerge.lookahead_on)\n') - _expect_error(src, "lookahead_on") + assert _errors(src) == [] + assert any("lookahead_on" in d.message for d in _warnings(src)) -def test_request_security_lookahead_on_positional_rejected(): +def test_request_security_lookahead_on_positional_warns(): src = (PRELUDE + 'a = request.security(syminfo.tickerid, "60", close, ' 'barmerge.gaps_off, barmerge.lookahead_on)\n') - _expect_error(src, "lookahead_on") + assert _errors(src) == [] + assert any("lookahead_on" in d.message for d in _warnings(src)) def test_request_security_gaps_barmerge_kwarg_passes(): diff --git a/tests/test_support_checker_chart_visible_bar_time.py b/tests/test_support_checker_chart_visible_bar_time.py index d1bfb5a..95a0a50 100644 --- a/tests/test_support_checker_chart_visible_bar_time.py +++ b/tests/test_support_checker_chart_visible_bar_time.py @@ -7,12 +7,15 @@ @pytest.mark.parametrize("member", ["left_visible_bar_time", "right_visible_bar_time"]) -def test_chart_visible_bar_time_rejected(member): +def test_chart_visible_bar_time_warns_not_rejected(member): + # Cosmetic / viewport-only read with no batch-mode meaning: warned + # (no-op), not rejected; codegen emits 0. src = f'''//@version=6 strategy("t") t = chart.{member} ''' tokens = Lexer(src).tokenize() ast = Parser(tokens).parse() - with pytest.raises(CompileError, match=member): - SupportChecker(ast).check_or_raise() + SupportChecker(ast).check_or_raise() # must not raise + diags = SupportChecker(ast).check() + assert any(member in d.message for d in diags) diff --git a/tests/test_support_checker_color_cast.py b/tests/test_support_checker_color_cast.py index 24a667a..53b0dfa 100644 --- a/tests/test_support_checker_color_cast.py +++ b/tests/test_support_checker_color_cast.py @@ -6,12 +6,15 @@ from pineforge_codegen.errors import CompileError -def test_bare_color_cast_rejected(): +def test_bare_color_cast_warns_not_rejected(): + # Bare color(...) cast is cosmetic (no backtest-logic effect): warned + # (no-op), not rejected; codegen emits a default color. src = '''//@version=6 strategy("t") x = color(close) ''' tokens = Lexer(src).tokenize() ast = Parser(tokens).parse() - with pytest.raises(CompileError, match=r"\bcolor\b"): - SupportChecker(ast).check_or_raise() + SupportChecker(ast).check_or_raise() # must not raise + diags = SupportChecker(ast).check() + assert any("color" in d.message for d in diags) diff --git a/tests/test_support_checker_const_namespaces.py b/tests/test_support_checker_const_namespaces.py index 0ff6ea8..533b1ce 100644 --- a/tests/test_support_checker_const_namespaces.py +++ b/tests/test_support_checker_const_namespaces.py @@ -59,9 +59,10 @@ def test_alert_freq_free_expression_rejected(): @pytest.mark.parametrize("member", ["bg_color", "fg_color"]) -def test_chart_colors_rejected(member): - with pytest.raises(CompileError, match=f"chart.{member}"): - transpile(PRELUDE + f"c = chart.{member}\n") +def test_chart_colors_warn_not_rejected(member): + # Cosmetic chart-theme reads: no backtest-logic effect. Transpile (no-op), + # not rejected; codegen emits a default color. + transpile(PRELUDE + f"c = chart.{member}\n") # --------------------------------------------------------------------------- @@ -108,14 +109,17 @@ def test_request_security_barmerge_kwargs_still_transpile(): transpile(src) -def test_request_security_lookahead_on_still_rejected(): - # The per-kwarg validation must keep working under the new suppression. +def test_request_security_lookahead_on_now_supported(): + # lookahead_on is engine-supported: it transpiles (no CompileError) and + # registers the HTF request with the lookahead flag set true. The per-kwarg + # value-shape validation still rejects non-barmerge values (see + # test_request_security_bad_gaps_still_rejected). src = PRELUDE + ( 'htf = request.security(syminfo.tickerid, "60", close, ' "lookahead=barmerge.lookahead_on)\n" ) - with pytest.raises(CompileError, match="lookahead_on"): - transpile(src) + out = transpile(src) + assert "input_tf_, true, false)" in out def test_request_security_bad_gaps_still_rejected(): From 97f48b1dfc4f66171355ef4c89392affe5e73f29 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 28 Jun 2026 21:18:33 +0800 Subject: [PATCH 3/4] feat(codegen): UDT na-sentinel (var T x = na) + bare-input rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more coverage fixes, guarded by the 1312-test suite. UDT vars (briancurry SDZone): `var SDZone z = na` declared the member as `double` (NA->double) because UDT detection only matched a `T.new(...)` initializer, not the analyzer's recorded type annotation — so a later `z := SDZone.new(...)` was "assigning to double from incompatible type SDZone". Now: type UDT var members from ctx._udt_var_types; every generated UDT struct carries a trailing `bool __pf_na` sentinel + an `is_na(const T&)` overload (so `na(udtVar)` compiles), `T.new(...)` sets it false, and the ctor skips UDT members (they default-construct to na). briancurry: compile-error -> 358 trades, entry/exit p90 0.0000%. Bare input() (jayentriken): a bare `input()` for a price-source input was mis-rendered, gating every entry off. Now rendered as the source series. jayentriken-bbwp: no-trades -> 562 trades (88% matched, entry p90 0.0000%). Co-Authored-By: Claude Opus 4.8 (1M context) --- pineforge_codegen/codegen/base.py | 28 +++++++++++++++++++------ pineforge_codegen/codegen/emit_top.py | 10 ++++++--- pineforge_codegen/codegen/input.py | 27 ++++++++++++++++++++++-- pineforge_codegen/codegen/visit_call.py | 5 +++++ pineforge_codegen/codegen/visit_stmt.py | 2 +- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 7b1f037..039fa50 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -902,8 +902,15 @@ def generate(self) -> str: else: default = self._default_for_spec(spec) lines.append(f" {cpp_type} {f.name} = {default};") + # NA sentinel (always the last data member). A default-constructed + # UDT - ``var T x = na``, an array fill slot, ``T.copy()`` no-arg - + # is na; the ``T.new(...)`` lowering sets this false. This lets + # ``na(udtVar)`` lower to the ``is_na(const T&)`` overload below + # instead of failing because no ``is_na`` accepts a struct. + lines.append(f" bool __pf_na = true;") lines.append(f" static {type_name} create() {{ return {type_name}{{}}; }}") lines.append("};") + lines.append(f"inline bool is_na(const {type_name}& _z) {{ return _z.__pf_na; }}") lines.append("") # 1c. Enum constants + string tables for str.tostring(enumVar) @@ -1048,13 +1055,22 @@ def generate(self) -> str: self._map_vars.add(name) lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(name))} {safe};") continue - # Detect UDT vars: init_str like "TypeName.new(...)" + # Detect UDT vars. Two signals: (1) the analyzer recorded an + # explicit UDT type annotation in ``_udt_var_types`` - this is the + # ONLY signal when the initializer is ``na`` (``var SDZone z = na``), + # where the inferred ``ptype`` is NA->double; (2) the init_str is a + # ``TypeName.new(...)`` constructor. Without (1) the member would + # decl as ``double`` and the later ``z = SDZone{...}`` would not + # compile (assigning SDZone to double). init_s = str(init_str) - udt_type = None - for udt_name in self._udt_defs: - if init_s.startswith(f"{udt_name}.new"): - udt_type = udt_name - break + udt_type = self._udt_var_types.get(name) + if udt_type not in self._udt_defs: + udt_type = None + if udt_type is None: + for udt_name in self._udt_defs: + if init_s.startswith(f"{udt_name}.new"): + udt_type = udt_name + break if udt_type: lines.append(f" {udt_type} {safe};") continue diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 8fc6852..b610f78 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -182,8 +182,7 @@ def _script_has_input_source(self) -> bool: for node in self._walk_ast(self.ctx.ast): if not isinstance(node, FuncCall): continue - func_name, namespace = self._resolve_callee(node.callee) - if namespace == "input" and func_name == "source": + if self._is_source_input(node): return True return False @@ -241,6 +240,11 @@ def _emit_constructor(self, lines: list[str]) -> None: safe = self._safe_name(name) if name in self._array_vars or name in self._map_vars: continue + # UDT-typed var members (``var SDZone z = na``) default-construct to + # na via the struct's in-class ``__pf_na = true``; a ctor init like + # ``z(na())`` would not type-match the struct member. + if name in self._udt_var_types and self._udt_var_types[name] in self._udt_defs: + continue if name not in self.ctx.series_vars: cpp_val = self._resolve_known(init_expr) cpp_val = self._typed_na_init(cpp_val, name, ptype) @@ -528,7 +532,7 @@ def _emit_on_bar(self, lines: list[str]) -> None: func_name_i, namespace_i = self._resolve_callee(stmt.value.callee) is_static_global_input = ( stmt.name in self._global_member_vars - and func_name_i != "source" + and not self._is_source_input(stmt.value) and stmt.name not in self._array_vars and stmt.name not in getattr(self, "_matrix_specs", {}) and stmt.name not in getattr(self, "_map_vars", {}) diff --git a/pineforge_codegen/codegen/input.py b/pineforge_codegen/codegen/input.py index 348a487..4133275 100644 --- a/pineforge_codegen/codegen/input.py +++ b/pineforge_codegen/codegen/input.py @@ -156,6 +156,27 @@ def _input_type_to_getter(func_name: str | None, namespace: str | None) -> str: return "get_input_int" return "get_input_double" + def _is_source_input(self, node: FuncCall) -> bool: + """True if an ``input.*`` call yields a *live per-bar source series*. + + Covers both ``input.source()`` and a bare + ``input()`` — in Pine v6 ``input(close)`` is the + source-input overload and behaves like ``input.source(close)``, + returning a series that tracks ``close`` every bar (not a constant + frozen at the first bar). The defval is restricted to the native + OHLCV series the engine can resolve at runtime (the same set + ``input.source`` is restricted to); a bare ``input(14)`` / + ``input(\"x\")`` / ``input(true)`` stays a frozen scalar.""" + func_name, namespace = self._resolve_callee(node.callee) + if namespace == "input" and func_name == "source": + return True + if func_name == "input" and namespace is None: + default = self._get_input_default(node) + if (isinstance(default, Identifier) + and default.name in self._NATIVE_SOURCE_SERIES): + return True + return False + def _source_defval_to_base_series(self, default) -> str: """Map an input.source defval (close/high/hl2/…) to its engine base source series member (``_src_close_`` …). Falls back to @@ -175,8 +196,10 @@ def _render_input_value(self, node: FuncCall, func_name: str | None, series and we read its current value; a subscripted source var is already tracked as a series var by the analyzer so ``src[1]`` lowers to a Series subscript. Every other input type routes through the - scalar getter table.""" - if namespace == "input" and func_name == "source": + scalar getter table. A bare ``input()`` is the Pine + source-input overload and is rendered identically to + ``input.source``.""" + if self._is_source_input(node): default = self._get_input_default(node) base = self._source_defval_to_base_series(default) return f'get_input_source("{title}", {base})[0]' diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index 050ded3..e741602 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -920,6 +920,11 @@ def _visit_func_call(self, node: FuncCall) -> str: if f_cpp_type == "int" and "na" in val: val = val.replace("na()", "0") field_inits.append(f".{f.name} = {val}") + # Mark the constructed object non-na (the struct's ``__pf_na`` is the + # last declared field, so this designator stays in declaration order). + # A bare default-constructed UDT keeps ``__pf_na = true`` (na); only a + # real ``.new(...)`` flips it false so ``na(obj)`` reports correctly. + field_inits.append(".__pf_na = false") return f"{namespace}{{{', '.join(field_inits)}}}" # UDT copy: TypeName.copy(obj) diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index 5558ff0..fd09b7e 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -258,7 +258,7 @@ def _visit_var_decl(self, node: VarDecl, lines: list[str], pad: str) -> None: if is_global_member and isinstance(node.value, FuncCall) and self._is_input_call(node.value): func_name_i, namespace_i = self._resolve_callee(node.value.callee) is_static_global_input = ( - func_name_i != "source" + not self._is_source_input(node.value) and node.name not in self._array_vars and node.name not in getattr(self, "_matrix_specs", {}) and node.name not in getattr(self, "_map_vars", {}) From eed7c34c2d3903fd3a095e6d0f80b64f8efe90ce Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Sun, 28 Jun 2026 21:34:55 +0800 Subject: [PATCH 4/4] test(gate): chart.bg_color is now an accepted no-op (err -> ok fixture) `chart.bg_color` (and the other chart/cosmetic reads) became warn+no-op instead of a hard reject in this branch, so the gate fixture moves from the expect-reject (err/) corpus to expect-accept (ok/). Only this one fixture flips; the other err/ fixtures still reject. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/gate-corpus/{err => ok}/chart_bg_color.pine | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/gate-corpus/{err => ok}/chart_bg_color.pine (100%) diff --git a/tests/gate-corpus/err/chart_bg_color.pine b/tests/gate-corpus/ok/chart_bg_color.pine similarity index 100% rename from tests/gate-corpus/err/chart_bg_color.pine rename to tests/gate-corpus/ok/chart_bg_color.pine