pineforge-codegen 0.6.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pineforge_codegen/__init__.py +53 -0
  2. pineforge_codegen/analyzer/__init__.py +60 -0
  3. pineforge_codegen/analyzer/base.py +1563 -0
  4. pineforge_codegen/analyzer/call_handlers.py +895 -0
  5. pineforge_codegen/analyzer/contracts.py +163 -0
  6. pineforge_codegen/analyzer/diagnostics.py +118 -0
  7. pineforge_codegen/analyzer/tables.py +204 -0
  8. pineforge_codegen/analyzer/types.py +250 -0
  9. pineforge_codegen/ast_nodes.py +293 -0
  10. pineforge_codegen/codegen/__init__.py +78 -0
  11. pineforge_codegen/codegen/base.py +1381 -0
  12. pineforge_codegen/codegen/emit_top.py +875 -0
  13. pineforge_codegen/codegen/helpers.py +163 -0
  14. pineforge_codegen/codegen/helpers_syminfo.py +134 -0
  15. pineforge_codegen/codegen/input.py +189 -0
  16. pineforge_codegen/codegen/security.py +1564 -0
  17. pineforge_codegen/codegen/ta.py +298 -0
  18. pineforge_codegen/codegen/tables.py +613 -0
  19. pineforge_codegen/codegen/types.py +573 -0
  20. pineforge_codegen/codegen/visit_call.py +1305 -0
  21. pineforge_codegen/codegen/visit_expr.py +701 -0
  22. pineforge_codegen/codegen/visit_stmt.py +729 -0
  23. pineforge_codegen/errors.py +98 -0
  24. pineforge_codegen/lexer.py +531 -0
  25. pineforge_codegen/parser.py +1198 -0
  26. pineforge_codegen/pragmas.py +117 -0
  27. pineforge_codegen/signatures.py +808 -0
  28. pineforge_codegen/support_checker.py +1111 -0
  29. pineforge_codegen/symbols.py +118 -0
  30. pineforge_codegen/tokens.py +406 -0
  31. pineforge_codegen/tv_input_choices.py +86 -0
  32. pineforge_codegen-0.6.5.dist-info/METADATA +462 -0
  33. pineforge_codegen-0.6.5.dist-info/RECORD +35 -0
  34. pineforge_codegen-0.6.5.dist-info/WHEEL +4 -0
  35. pineforge_codegen-0.6.5.dist-info/licenses/LICENSE +197 -0
@@ -0,0 +1,1305 @@
1
+ """Function-call dispatch visitors for the codegen.
2
+
3
+ ``CallVisitor`` mixin holds the function-call dispatcher and the
4
+ per-namespace dispatch helpers (``strategy.*``, ``color.*``, ``str.*``,
5
+ ``math.*``, ``fixnan``).
6
+
7
+ ``_visit_func_call`` is the central entry point for any
8
+ :class:`FuncCall` AST node. It first handles UDT-method calls and
9
+ ``obj.method(args)`` style receivers, then resolves the callee to a
10
+ ``(func_name, namespace)`` pair and dispatches by namespace:
11
+
12
+ * ``strategy.*`` -> ``_visit_strategy_call`` (entry / exit / close /
13
+ cancel / order / closedtrades.* / opentrades.* / convert_to_*).
14
+ * ``ta.*`` -> ``ta.tr`` is inlined; other sites resolve to the
15
+ ``member.compute(...)`` / ``member.recompute(...)`` form via the
16
+ ``TaSiteHelper``; ``ta.pivot_point_levels`` is a free function.
17
+ * ``input`` / ``input.*`` -> runtime ``get_input_*()`` getters via
18
+ ``InputHelper``.
19
+ * ``str.*`` -> ``_visit_str_call`` (tostring / substring / format
20
+ / format_time / replace + the ``STR_FUNC_MAP`` shortcuts).
21
+ * ``math.*`` -> ``_visit_math_call`` (round-to-mintick, todegrees /
22
+ toradians, random, n-ary avg / max / min, ``MATH_FUNC_MAP``).
23
+ * ``color.*`` -> ``_visit_color_call`` (new / r / g / b / t / rgb /
24
+ from_gradient).
25
+ * ``array.*`` / ``map.*`` / ``matrix.*`` -> functional and method-syntax
26
+ forms, delegating to ``TypeInferer``'s ``_array_method_expr`` /
27
+ ``_map_method_expr`` and the ``MATRIX_METHODS`` table.
28
+ * ``request.security``, ``ticker.*``, ``runtime.*``, ``log.*``,
29
+ ``timeframe.*``, ``time`` / ``time_close`` / ``timestamp``, type
30
+ casts ``int`` / ``float`` / ``bool`` / ``string``.
31
+ * UDT constructors (``TypeName.new(...)``) and copies.
32
+
33
+ Anything left over is treated as a generic user-defined or unknown
34
+ function call: the visitor merges kwargs by parameter name (using
35
+ ``FuncInfo`` for user functions or the ``signatures`` registry for
36
+ intrinsics), passes ``Series<T>`` references for series-typed params,
37
+ and emits ``namespace::func(args)`` (or the per-call-site variant
38
+ ``func_csN`` for functions cloned by the analyzer's call-site
39
+ splitter).
40
+
41
+ ``_visit_fixnan`` allocates a fresh persistent state member each time
42
+ it is called (``_prev_fixnan_<n>``).
43
+
44
+ ``_resolve_func_args`` is a small helper that merges positional args
45
+ and kwargs into a ``{param_name: arg_node}`` dict using the parameter
46
+ ordering from the ``signatures`` registry; used by
47
+ ``_visit_strategy_call`` to resolve ``strategy.entry`` /
48
+ ``strategy.exit`` / ... by keyword.
49
+
50
+ These visitors were extracted from ``base.py``'s ``CodeGen`` class as
51
+ step 10 of the codegen package refactor; behaviour is preserved
52
+ verbatim. The mixin owns no state of its own — it reads/writes only
53
+ attributes already established on the host class (``CodeGen``).
54
+
55
+ Mixin contract — host class must provide the following attributes
56
+ (all set by ``CodeGen.__init__`` or other mixins):
57
+
58
+ - ``self.ctx`` (``AnalyzerContext``): symbol table source. The
59
+ visitors read ``ctx.symbols.resolve`` (``_visit_str_call`` enum
60
+ branch), ``ctx.series_vars`` (series-arg lowering),
61
+ ``ctx.func_series_vars`` (per-function series-param indices),
62
+ ``ctx.func_call_cs_map`` and ``ctx.func_call_site_counts``
63
+ (per-call-site variant naming).
64
+ - ``self._var_names`` (``set[str]``): names declared at module scope;
65
+ consulted by the ``obj.method`` receiver-detection branch.
66
+ - ``self._global_member_vars`` (``set[str]``): non-``var`` global
67
+ declarations emitted as class members (same branch).
68
+ - ``self._current_func_param_types`` (``dict[str, ...]``): parameters
69
+ of the function currently being emitted (treated as locals for the
70
+ receiver guard).
71
+ - ``self._current_loop_vars`` (``set[str]``): for-in iterator names
72
+ (also receivers of ``.method()`` calls).
73
+ - ``self._current_input_var_name`` (``str | None``): contextual var
74
+ name used as the title-fallback for ``input(...)`` calls.
75
+ - ``self._array_vars`` / ``self._map_vars`` (``set[str]``) and
76
+ ``self._matrix_specs`` (``dict[str, TypeSpec]``): collection-typed
77
+ variables; gate the receiver-method branches in ``_visit_func_call``.
78
+ - ``self._udt_defs`` (``dict[str, list]``), ``self._udt_var_types``
79
+ (``dict[str, str]``), ``self._udt_param_udt`` (``dict[str, str]``),
80
+ ``self._udt_field_type_specs`` (``dict[str, dict[str, TypeSpec]]``):
81
+ UDT type info for ``TypeName.new(...)`` constructors,
82
+ ``TypeName.copy(...)``, and the ``obj.method()`` UDT-method dispatch.
83
+ - ``self._enum_member_strings`` (``dict[str, list[str]]``): enum -->
84
+ display-string table; used by ``_visit_str_call`` to render
85
+ ``str.tostring(enumVar)`` as the field title rather than the int
86
+ index.
87
+ - ``self._func_info_map`` (``dict[str, FuncInfo]``): user-defined
88
+ function lookup; drives kwarg merging, UDT method dispatch, and the
89
+ series-arg classification.
90
+ - ``self._func_names`` (``set[str]``): user-defined function names;
91
+ controls when ``_func_safe_name`` is applied and when the
92
+ per-call-site variant naming kicks in.
93
+ - ``self._security_calls`` (``list[dict]``): normalized
94
+ ``request.security`` records; matched by ``expr_node`` identity to
95
+ bind ``_req_sec_<id>`` result names.
96
+ - ``self._active_call_site_idx`` (``int | None``): set during
97
+ per-call-site function emission; controls ``_csN`` variant naming
98
+ for sub-function calls.
99
+ - ``self._active_var_remap`` (``dict[str, str]``): per-call-site
100
+ rename map for cloned function-local var/series names; consulted by
101
+ the series-arg lowering helper.
102
+ - ``self._fixnan_counter`` (``int``): monotonically incremented by
103
+ ``_visit_fixnan`` to mint fresh ``_prev_fixnan_<n>`` member names.
104
+ - ``self._random_call_counter`` (``int``): monotonically incremented
105
+ by ``_visit_math_call`` for ``math.random`` so each call gets a
106
+ unique site id (used to seed the runtime PRNG).
107
+
108
+ Sibling-mixin methods consumed via ``self``:
109
+
110
+ - ``NamingHelper`` (``codegen/helpers.py``): ``_safe_name``,
111
+ ``_resolve_callee``, ``_func_safe_name``.
112
+ - ``TypeInferer`` (``codegen/types.py``): ``_type_spec_to_cpp``,
113
+ ``_type_spec_from_expr``, ``_type_spec_from_hint_name``,
114
+ ``_default_for_spec``, ``_array_method_expr``,
115
+ ``_map_method_expr``, ``_array_spec_for_name``,
116
+ ``_map_spec_for_name``.
117
+ - ``TaSiteHelper`` (``codegen/ta.py``): ``_get_ta_site``,
118
+ ``_ta_member_name``, ``_ta_compute_args_for_site``.
119
+ - ``InputHelper`` (``codegen/input.py``): ``_is_input_call_by_name``,
120
+ ``_get_input_default``, ``_get_input_title``,
121
+ ``_input_type_to_getter``,
122
+ ``_enforce_enum_declared_before_input_enum``.
123
+ - ``TopLevelEmitter`` (``codegen/emit_top.py``):
124
+ ``_emit_udt_method_cpp_name``.
125
+ - ``ExprVisitor`` (``codegen/visit_expr.py``): ``_visit_expr``.
126
+ - ``CodeGen.base``: ``_codegen_error``.
127
+
128
+ The mixin avoids importing from ``base.py`` to stay free of cycles;
129
+ all tables it needs come from ``codegen/tables.py``, AST classes from
130
+ ``..ast_nodes``, and PineScript signatures from ``.. import signatures``.
131
+ """
132
+
133
+ from __future__ import annotations
134
+
135
+ from ..ast_nodes import (
136
+ FuncCall,
137
+ Identifier,
138
+ MemberAccess,
139
+ TupleLiteral,
140
+ StringLiteral,
141
+ )
142
+ from ..symbols import TypeSpec
143
+ from .. import signatures as sigs
144
+ from .tables import (
145
+ ARRAY_METHODS,
146
+ BAR_FIELDS,
147
+ BAR_SERIES_PUSH,
148
+ MAP_METHODS,
149
+ MATH_FUNC_MAP,
150
+ MATRIX_METHODS,
151
+ MATRIX_METHOD_KWARGS,
152
+ MATRIX_NUMERIC_ONLY,
153
+ MATRIX_SORT_ALLOWED_GENERIC_ELEMS,
154
+ SKIP_FUNC_NAMES,
155
+ SKIP_NAMESPACES,
156
+ SKIP_VAR_TYPES,
157
+ STR_FUNC_MAP,
158
+ _merge_kwargs,
159
+ _merge_kwargs_with_defaults,
160
+ )
161
+
162
+
163
+ class CallVisitor:
164
+ """Function-call dispatch visitor methods shared across the codegen.
165
+
166
+ Mixed into ``CodeGen``; not intended to be instantiated standalone.
167
+ See the module docstring for the full host-class state contract."""
168
+
169
+ # ------------------------------------------------------------------
170
+ # Function-call dispatch
171
+ # ------------------------------------------------------------------
172
+
173
+ def _visit_func_call(self, node: FuncCall) -> str:
174
+ callee = node.callee
175
+ if isinstance(callee, MemberAccess):
176
+ recv_spec = self._type_spec_from_expr(callee.object)
177
+ if recv_spec is not None and recv_spec.kind == "udt" and recv_spec.name:
178
+ mk = f"{recv_spec.name}.{callee.member}"
179
+ fi_u = self._func_info_map.get(mk)
180
+ if fi_u is not None and getattr(fi_u, "is_udt_method", False):
181
+ fn_cpp = self._emit_udt_method_cpp_name(fi_u)
182
+ recv_e = self._visit_expr(callee.object)
183
+ param_names = list(fi_u.node.params[1:]) if fi_u.node else []
184
+ # Drop the leading ``self`` slot from param_defaults so the
185
+ # parallel array lines up with ``param_names`` (rest of
186
+ # the signature). Probe: udt-method-probe-04-default-param.
187
+ param_defaults = list(getattr(fi_u, "param_defaults", []) or [])[1:]
188
+ rest_nodes = _merge_kwargs_with_defaults(
189
+ node.args, node.kwargs, param_names,
190
+ param_defaults, lambda x: x,
191
+ )
192
+ rest = [self._visit_expr(a) for a in rest_nodes]
193
+ return f"{fn_cpp}({', '.join([recv_e] + rest)})"
194
+ # obj.field.method(args) — must not lower to namespace::method (loses receiver chain).
195
+ if isinstance(callee, MemberAccess):
196
+ obj = callee.object
197
+ if isinstance(obj, MemberAccess):
198
+ root = obj.object
199
+ if not (isinstance(root, Identifier) and root.name in (
200
+ "strategy", "ta", "math", "input", "str", "timeframe", "syminfo",
201
+ "barstate", "color", "request", "runtime", "array", "matrix", "map",
202
+ )):
203
+ recv_spec = self._type_spec_from_expr(obj)
204
+ recv = self._visit_expr(obj)
205
+ meth = callee.member
206
+ raw_args = [self._visit_expr(a) for a in node.args]
207
+ if recv_spec is not None and recv_spec.kind == "array" and meth in ARRAY_METHODS:
208
+ return self._array_method_expr(recv, meth, raw_args, recv_spec)
209
+ if recv_spec is not None and recv_spec.kind == "map" and meth in MAP_METHODS:
210
+ return self._map_method_expr(recv, meth, raw_args, recv_spec)
211
+ args = ", ".join(raw_args)
212
+ if meth == "delete":
213
+ meth = "_delete_"
214
+ return f"{recv}.{meth}({args})"
215
+ # obj.method() where obj is a user var/param — not namespace::method
216
+ if isinstance(obj, Identifier):
217
+ oname = obj.name
218
+ if (
219
+ oname in self._var_names
220
+ or oname in self._current_func_param_types
221
+ or oname in self._current_loop_vars
222
+ or oname in self._global_member_vars
223
+ ):
224
+ meth_raw = callee.member
225
+ # map.put / map.get / … must lower to unordered_map ops, not `.put` on C++
226
+ if oname in self._map_vars and meth_raw in MAP_METHODS:
227
+ m = self._safe_name(oname)
228
+ margs = [self._visit_expr(a) for a in node.args]
229
+ return self._map_method_expr(m, meth_raw, margs, self._map_spec_for_name(oname))
230
+ if oname in self._array_vars and meth_raw in ARRAY_METHODS:
231
+ arr = self._safe_name(oname)
232
+ margs = [self._visit_expr(a) for a in node.args]
233
+ return self._array_method_expr(arr, meth_raw, margs, self._array_spec_for_name(oname))
234
+ if oname in self._matrix_specs and meth_raw in MATRIX_METHODS:
235
+ arr = self._safe_name(oname)
236
+ self._check_matrix_method_allowed(meth_raw, self._matrix_specs[oname], node)
237
+ param_names = MATRIX_METHOD_KWARGS.get(meth_raw)
238
+ if param_names and node.kwargs:
239
+ margs = _merge_kwargs(
240
+ node.args, node.kwargs, param_names, self._visit_expr
241
+ )
242
+ else:
243
+ margs = [self._visit_expr(a) for a in node.args]
244
+ fn = MATRIX_METHODS[meth_raw]
245
+ try:
246
+ return fn(arr, margs)
247
+ except IndexError:
248
+ self._codegen_error(
249
+ node,
250
+ f"matrix.{meth_raw}: wrong number of arguments",
251
+ hint="Check Pine v6 matrix method signature (positional vs keyword).",
252
+ )
253
+ safe_o = self._safe_name(oname)
254
+ udt_t = self._udt_var_types.get(oname) or self._udt_var_types.get(safe_o)
255
+ if udt_t is None:
256
+ udt_t = self._udt_param_udt.get(oname) or self._udt_param_udt.get(safe_o)
257
+ if udt_t is not None:
258
+ mk = f"{udt_t}.{meth_raw}"
259
+ fi_u = self._func_info_map.get(mk)
260
+ if fi_u is not None and getattr(fi_u, "is_udt_method", False):
261
+ fn_cpp = self._emit_udt_method_cpp_name(fi_u)
262
+ recv_e = self._visit_expr(obj)
263
+ param_names = list(fi_u.node.params[1:]) if fi_u.node else []
264
+ # Drop the leading ``self`` slot so param_defaults
265
+ # lines up with ``param_names``. Probe:
266
+ # udt-method-probe-04-default-param.
267
+ param_defaults = list(getattr(fi_u, "param_defaults", []) or [])[1:]
268
+ rest_nodes = _merge_kwargs_with_defaults(
269
+ node.args, node.kwargs, param_names,
270
+ param_defaults, lambda x: x,
271
+ )
272
+ rest = [self._visit_expr(a) for a in rest_nodes]
273
+ return f"{fn_cpp}({', '.join([recv_e] + rest)})"
274
+ args = ", ".join(self._visit_expr(a) for a in node.args)
275
+ recv = self._visit_expr(obj)
276
+ meth = meth_raw
277
+ if meth == "delete":
278
+ meth = "_delete_"
279
+ return f"{recv}.{meth}({args})"
280
+
281
+ func_name, namespace = self._resolve_callee(callee)
282
+
283
+ # na(x) -> is_na(x)
284
+ if func_name == "na" and namespace is None:
285
+ args = ", ".join(self._visit_expr(a) for a in node.args)
286
+ return f"is_na({args})"
287
+
288
+ # nz(x) / nz(x, y)
289
+ if func_name == "nz" and namespace is None:
290
+ x = self._visit_expr(node.args[0])
291
+ y = self._visit_expr(node.args[1]) if len(node.args) > 1 else "0.0"
292
+ return f"(is_na({x}) ? {y} : {x})"
293
+
294
+ # fixnan(x) -> persistent state
295
+ if func_name == "fixnan" and namespace is None:
296
+ return self._visit_fixnan(node)
297
+
298
+ # strategy.* calls
299
+ if namespace == "strategy":
300
+ return self._visit_strategy_call(func_name, node)
301
+
302
+ # ta.tr(handle_na) is dispatched through the standard TA-class path
303
+ # below: the analyzer assigns it a ``ta::TR`` call site (with
304
+ # ``handle_na`` threaded into the constructor); the property form
305
+ # ``ta.tr`` (no parens) stays inline in ``visit_expr`` so its
306
+ # legacy ``handle_na = true`` semantics remain bit-identical.
307
+
308
+ # ta.* calls -> member.compute(...)
309
+ site = self._get_ta_site(node)
310
+ if site is not None:
311
+ compute_args = self._ta_compute_args_for_site(site)
312
+ ta_mem = self._ta_member_name(site)
313
+ if getattr(self, "_precalc_loop_active", False) and getattr(site, "is_static", False):
314
+ return f"_precalc_{ta_mem}[i]"
315
+ if getattr(site, "is_static", False):
316
+ return f"(_use_precalc ? _precalc_{ta_mem}[bar_index_] : (is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args})))"
317
+ return f"(is_first_tick_ ? {ta_mem}.compute({compute_args}) : {ta_mem}.recompute({compute_args}))"
318
+
319
+ # math.* calls
320
+ if namespace == "math":
321
+ return self._visit_math_call(func_name, node)
322
+
323
+ # input() / input.* calls -> runtime get_input_*()
324
+ if self._is_input_call_by_name(func_name, namespace):
325
+ if namespace == "input" and func_name == "enum":
326
+ self._enforce_enum_declared_before_input_enum(node)
327
+ title = self._get_input_title(node, var_name=self._current_input_var_name)
328
+ return self._render_input_value(node, func_name, namespace, title)
329
+
330
+ # strategy() declaration
331
+ if func_name == "strategy" and namespace is None:
332
+ return "/* strategy declaration */"
333
+
334
+ # str.* calls
335
+ if namespace == "str":
336
+ return self._visit_str_call(func_name, node)
337
+
338
+ # Map method syntax: m.put(key, val) where namespace is the map variable name
339
+ if namespace is not None and namespace in self._map_vars and func_name in MAP_METHODS:
340
+ m = self._safe_name(namespace)
341
+ args = [self._visit_expr(a) for a in node.args]
342
+ return self._map_method_expr(m, func_name, args, self._map_spec_for_name(namespace))
343
+
344
+ # map.method(m, args...) — functional form
345
+ if namespace == "map":
346
+ if func_name == "new":
347
+ spec = self._type_spec_from_expr(node) or TypeSpec.map(TypeSpec.primitive("string"), TypeSpec.primitive("float"))
348
+ return f"{self._type_spec_to_cpp(spec)}()"
349
+ if func_name in MAP_METHODS and node.args:
350
+ m = self._visit_expr(node.args[0])
351
+ rest = [self._visit_expr(a) for a in node.args[1:]]
352
+ spec = self._type_spec_from_expr(node.args[0]) if node.args else None
353
+ return self._map_method_expr(m, func_name, rest, spec)
354
+ return "0"
355
+
356
+ if namespace is not None and namespace in self._matrix_specs and func_name in MATRIX_METHODS:
357
+ arr = self._safe_name(namespace)
358
+ self._check_matrix_method_allowed(func_name, self._matrix_specs[namespace], node)
359
+ param_names = MATRIX_METHOD_KWARGS.get(func_name)
360
+ if param_names and node.kwargs:
361
+ args = _merge_kwargs(node.args, node.kwargs, param_names, self._visit_expr)
362
+ else:
363
+ args = [self._visit_expr(a) for a in node.args]
364
+ fn = MATRIX_METHODS[func_name]
365
+ try:
366
+ return fn(arr, args)
367
+ except IndexError:
368
+ self._codegen_error(
369
+ node,
370
+ f"matrix.{func_name}: wrong number of arguments",
371
+ hint="Check Pine v6 matrix method signature (positional vs keyword).",
372
+ )
373
+
374
+ # Array method syntax: arr.push(val) where namespace is the array variable name
375
+ if namespace is not None and namespace in self._array_vars and func_name in ARRAY_METHODS:
376
+ arr = self._safe_name(namespace)
377
+ args = [self._visit_expr(a) for a in node.args]
378
+ return self._array_method_expr(arr, func_name, args, self._array_spec_for_name(namespace))
379
+
380
+ # Array operations — emit proper C++ vector operations
381
+ if namespace == "array":
382
+ if func_name in ("new", "new_float", "new_int", "new_bool", "new_string"):
383
+ spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
384
+ cpp_type = self._type_spec_to_cpp(spec)
385
+ init_default = self._default_for_spec(spec.element if spec.element is not None else TypeSpec.primitive("float"))
386
+ if node.args:
387
+ size_arg = self._visit_expr(node.args[0])
388
+ init_val = self._visit_expr(node.args[1]) if len(node.args) > 1 else init_default
389
+ return f"{cpp_type}((size_t)({size_arg}), {init_val})"
390
+ return f"{cpp_type}()"
391
+ if func_name == "from":
392
+ spec = self._type_spec_from_expr(node) or TypeSpec.array(TypeSpec.primitive("float"))
393
+ elems = ", ".join(self._visit_expr(a) for a in node.args)
394
+ return f"{self._type_spec_to_cpp(spec)}{{{elems}}}"
395
+ # Method calls: array.method(arr, args...)
396
+ if func_name in ARRAY_METHODS and node.args:
397
+ arr = self._visit_expr(node.args[0])
398
+ rest = [self._visit_expr(a) for a in node.args[1:]]
399
+ spec = self._type_spec_from_expr(node.args[0])
400
+ return self._array_method_expr(arr, func_name, rest, spec)
401
+ return "0"
402
+
403
+ # color.* calls
404
+ if namespace == "color":
405
+ return self._visit_color_call(func_name, node)
406
+
407
+ # Skip visual/unsupported namespace calls
408
+ if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES:
409
+ return "0"
410
+ if func_name in SKIP_FUNC_NAMES and namespace is None:
411
+ return "0"
412
+
413
+ # request.* calls
414
+ if namespace == "request":
415
+ if func_name == "security":
416
+ param_names = ["symbol", "timeframe", "expression", "gaps", "lookahead", "ignore_invalid_symbol", "currency"]
417
+ all_args = list(node.args)
418
+ for i, pname in enumerate(param_names):
419
+ if pname in node.kwargs:
420
+ while len(all_args) <= i:
421
+ all_args.append(None)
422
+ all_args[i] = node.kwargs[pname]
423
+
424
+ # Find matching security call ID
425
+ sec_id = None
426
+ tf_node = None
427
+ expr_node = None
428
+ for item in self._security_calls:
429
+ sid, tfn, exprn = item["sec_id"], item["tf_node"], item["expr_node"]
430
+ if item.get("is_lower_tf_array"):
431
+ continue
432
+ if exprn is all_args[2] if len(all_args) > 2 else False:
433
+ sec_id = sid
434
+ tf_node = tfn
435
+ expr_node = exprn
436
+ break
437
+
438
+ if sec_id is not None and expr_node is not None:
439
+ if isinstance(expr_node, TupleLiteral):
440
+ parts = []
441
+ for i, el in enumerate(expr_node.elements):
442
+ parts.append(f"_req_sec_{sec_id}_{i}")
443
+ return f"std::make_tuple({', '.join(parts)})"
444
+ return f"_req_sec_{sec_id}"
445
+
446
+ # Fallback
447
+ return "na<double>()"
448
+ if func_name == "security_lower_tf":
449
+ # ``request.security_lower_tf`` is matched against the
450
+ # registered SecurityCallInfo by AST identity of the
451
+ # ``expression`` argument (3rd positional or kwarg). The
452
+ # codegen lowers the call to the per-sec_id accumulator
453
+ # vector — its element type and clear/push semantics are
454
+ # set up by the security mixin.
455
+ ltf_param_names = [
456
+ "symbol", "timeframe", "expression",
457
+ "ignore_invalid_symbol", "currency",
458
+ "ignore_invalid_timeframe", "calc_bars_count",
459
+ ]
460
+ ltf_all_args = list(node.args)
461
+ for i, pname in enumerate(ltf_param_names):
462
+ if pname in node.kwargs:
463
+ while len(ltf_all_args) <= i:
464
+ ltf_all_args.append(None)
465
+ ltf_all_args[i] = node.kwargs[pname]
466
+ ltf_expr_node = ltf_all_args[2] if len(ltf_all_args) > 2 else None
467
+ for item in self._security_calls:
468
+ if not item.get("is_lower_tf_array"):
469
+ continue
470
+ if item["expr_node"] is ltf_expr_node:
471
+ return f"_req_sec_lower_tf_{item['sec_id']}"
472
+ return "std::vector<double>{}"
473
+ # All other request.* functions
474
+ return "na<double>()"
475
+
476
+ # ticker.* calls
477
+ if namespace == "ticker":
478
+ # ticker.inherit(symbol, ...) and ticker.standard(symbol) — passthrough:
479
+ # emit the symbol argument unchanged (same-symbol passthrough).
480
+ if func_name in ("inherit", "standard"):
481
+ if node.args:
482
+ return self._visit_expr(node.args[0])
483
+ if "symbol" in node.kwargs:
484
+ return self._visit_expr(node.kwargs["symbol"])
485
+ # All other ticker.* calls are hard-rejected by support_checker;
486
+ # emit empty string as safe fallback if they somehow reach codegen.
487
+ return 'std::string("")'
488
+
489
+ # runtime.error() and other runtime.* calls
490
+ if namespace == "runtime":
491
+ if func_name == "error":
492
+ rt_args = [self._visit_expr(a) for a in node.args]
493
+ msg_arg = rt_args[0] if rt_args else '""'
494
+ return f'pine_runtime_error({msg_arg})'
495
+ return '"" /* unsupported runtime */'
496
+
497
+ # year(time) / month(time) / dayofmonth(time) / dayofweek(time) /
498
+ # hour(time[, tz]) / minute(time[, tz]) / second(time[, tz]) /
499
+ # weekofyear(time[, tz]).
500
+ #
501
+ # Pine v6 exposes these names as BOTH variables (current bar) AND
502
+ # functions (arbitrary timestamp). The variable form is wired by
503
+ # ``BAR_BUILTINS`` in codegen/tables.py to ``_bar_year()`` etc. The
504
+ # function form has no public runtime helper, so we inline the
505
+ # gmtime_r-based calculation that mirrors ``BacktestEngine::
506
+ # _decompose_bar_time()`` (see include/pineforge/engine.hpp) so the
507
+ # numbers agree across both forms.
508
+ #
509
+ # Timezone handling (per Pine v6 reference docs):
510
+ # - Bare form ``hour(time)`` defaults its tz argument to
511
+ # ``syminfo.timezone`` — the SYMBOL/EXCHANGE timezone, NOT the
512
+ # chart's display timezone. For the corpus' ETH-USDT crypto data
513
+ # this is ``"UTC"`` (the ``SymInfo`` constructor default), which
514
+ # keeps the lambda on the cheap ``gmtime_r`` fast path. The
515
+ # variable form ``hour`` is wired directly to ``_bar_hour()`` /
516
+ # ``_decompose_bar_time()`` (engine.hpp), which is hardcoded to
517
+ # ``gmtime_r`` and therefore matches the function-form default
518
+ # for the same exchange TZ.
519
+ #
520
+ # Pre-fix the harness's ``strategy_set_chart_timezone`` clobbered
521
+ # ``syminfo_.timezone`` with the chart display TZ, which silently
522
+ # shifted ``hour(time)``-bucketed accumulators by the
523
+ # chart-vs-exchange offset (Asia/Taipei vs UTC = +8h). That fix
524
+ # now lives entirely in ``BacktestEngine::set_chart_timezone``
525
+ # (engine.hpp), which writes to a dedicated ``chart_timezone_``
526
+ # slot and leaves ``syminfo_.timezone`` at its constructor
527
+ # default. This codegen still reads ``syminfo_.timezone``,
528
+ # matching TV semantics, with no emit-time changes.
529
+ # - Two-arg form ``hour(time, tz)`` always overrides syminfo with
530
+ # the explicit tz argument. Same setenv+localtime_r block as the
531
+ # 1-arg fallback.
532
+ _BAR_TIME_FUNC_EXPR = {
533
+ "year": "tm_buf.tm_year + 1900",
534
+ "month": "tm_buf.tm_mon + 1",
535
+ "dayofmonth": "tm_buf.tm_mday",
536
+ "dayofweek": "tm_buf.tm_wday + 1",
537
+ "hour": "tm_buf.tm_hour",
538
+ "minute": "tm_buf.tm_min",
539
+ "second": "tm_buf.tm_sec",
540
+ "weekofyear": "(tm_buf.tm_yday + 7 - ((tm_buf.tm_wday + 6) % 7)) / 7",
541
+ }
542
+ if (
543
+ namespace is None
544
+ and func_name in _BAR_TIME_FUNC_EXPR
545
+ and (node.args or node.kwargs)
546
+ ):
547
+ params = sigs.get_param_names(None, func_name)
548
+ args = _merge_kwargs(node.args, node.kwargs, params, self._visit_expr)
549
+ ts_arg = args[0] if args else "current_bar_.timestamp"
550
+ tz_arg = args[1] if len(args) > 1 else None
551
+ field_expr = _BAR_TIME_FUNC_EXPR[func_name]
552
+ if tz_arg is None:
553
+ # 1-arg form — fall back to ``syminfo.timezone`` per TV
554
+ # docs (the EXCHANGE TZ, default "UTC" for the corpus'
555
+ # crypto data; NOT the chart display TZ — that lives in
556
+ # ``chart_timezone_`` on the engine and is intentionally
557
+ # ignored here). UTC / "" / "Etc/UTC" stay on the cheap
558
+ # gmtime_r path; anything else takes the same
559
+ # mutex-guarded setenv+localtime_r block as the 2-arg
560
+ # form.
561
+ tz_arg = "syminfo_.timezone"
562
+ # 2-arg form — honor the tz argument. Uses an inline setenv+
563
+ # localtime_r guarded by a function-local static mutex, mirroring
564
+ # ``pine_tz::ScopedTimezone`` (src/timezone.cpp) which is not
565
+ # exposed via any public ``<pineforge/...>`` header today.
566
+ return (
567
+ "[&]() -> int { "
568
+ f"std::string _tz = ({tz_arg}); "
569
+ f"time_t _secs = (time_t)(({ts_arg}) / 1000); "
570
+ "struct tm tm_buf; "
571
+ "if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") { "
572
+ "gmtime_r(&_secs, &tm_buf); "
573
+ "} else { "
574
+ "static std::mutex _pf_tz_mu; "
575
+ "std::lock_guard<std::mutex> _pf_tz_lock(_pf_tz_mu); "
576
+ "const char* _old = std::getenv(\"TZ\"); "
577
+ "std::string _old_tz = _old ? _old : \"\"; bool _had_old = (_old != nullptr); "
578
+ "::setenv(\"TZ\", _tz.c_str(), 1); ::tzset(); "
579
+ "localtime_r(&_secs, &tm_buf); "
580
+ "if (_had_old) { ::setenv(\"TZ\", _old_tz.c_str(), 1); } "
581
+ "else { ::unsetenv(\"TZ\"); } ::tzset(); "
582
+ "} "
583
+ f"return {field_expr}; "
584
+ "}()"
585
+ )
586
+
587
+ # time(timeframe) or time(timeframe, session[, tz])
588
+ if func_name == "time" and namespace is None and (node.args or node.kwargs):
589
+ args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time"), self._visit_expr)
590
+ tf_e = args[0] if len(args) > 0 else 'script_tf_'
591
+ sess = args[1] if len(args) > 1 else 'std::string("")'
592
+ tz_e = args[2] if len(args) > 2 else 'std::string("")'
593
+ return (
594
+ f"pine_time(current_bar_.timestamp, {tf_e}, {sess}, {tz_e}, script_tf_)"
595
+ )
596
+ # time_close(timeframe) or time_close(tf, session, tz)
597
+ if func_name == "time_close" and namespace is None and (node.args or node.kwargs):
598
+ args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names(None, "time_close"), self._visit_expr)
599
+ tf_e = args[0] if len(args) > 0 else 'script_tf_'
600
+ sess = args[1] if len(args) > 1 else 'std::string("")'
601
+ tz_e = args[2] if len(args) > 2 else 'std::string("")'
602
+ return (
603
+ f"pine_time_close(current_bar_.timestamp, {tf_e}, {sess}, {tz_e}, script_tf_)"
604
+ )
605
+
606
+ # timestamp(year, month, day, hour, minute) → Unix ms
607
+ if func_name == "timestamp" and namespace is None:
608
+ is_tz_first = False
609
+ if node.args:
610
+ first_arg_spec = self._type_spec_from_expr(node.args[0])
611
+ if first_arg_spec is not None and first_arg_spec.kind == "primitive" and first_arg_spec.name == "string":
612
+ is_tz_first = True
613
+ elif isinstance(node.args[0], StringLiteral):
614
+ is_tz_first = True
615
+
616
+ if is_tz_first:
617
+ args = [self._visit_expr(a) for a in node.args]
618
+ tz = args[0]
619
+ yr = args[1] if len(args) > 1 else "1970"
620
+ mo = args[2] if len(args) > 2 else "1"
621
+ dy = args[3] if len(args) > 3 else "1"
622
+ hr = args[4] if len(args) > 4 else "0"
623
+ mn = args[5] if len(args) > 5 else "0"
624
+ sc = args[6] if len(args) > 6 else "0"
625
+ return (
626
+ f"[&]() -> int64_t {{ "
627
+ f"std::string _tz = ({tz}); "
628
+ f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
629
+ f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
630
+ f"static thread_local std::string _last_tz; "
631
+ f"static thread_local int _last_yr = -1, _last_mo = -1, _last_dy = -1, _last_hr = -1, _last_min = -1, _last_sc = -1; "
632
+ f"static thread_local int64_t _last_res = -1; "
633
+ f"if (_last_res != -1 && _last_tz == _tz && _last_yr == _yr && _last_mo == _mo && _last_dy == _dy && _last_hr == _hr && _last_min == _min && _last_sc == _sc) {{ "
634
+ f"return _last_res; "
635
+ f"}} "
636
+ f"struct tm t = {{}}; "
637
+ f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
638
+ f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
639
+ f"int64_t _res; "
640
+ f"if (_tz.empty() || _tz == \"UTC\" || _tz == \"Etc/UTC\") {{ "
641
+ f"_res = (int64_t)timegm(&t) * 1000; "
642
+ f"}} else {{ "
643
+ f"static std::mutex _pf_ts_mu; "
644
+ f"std::lock_guard<std::mutex> _pf_ts_mu_lock(_pf_ts_mu); "
645
+ f"const char* _old = std::getenv(\"TZ\"); "
646
+ f"std::string _old_tz = _old ? _old : \"\"; bool _had_old = (_old != nullptr); "
647
+ f"::setenv(\"TZ\", _tz.c_str(), 1); ::tzset(); "
648
+ f"_res = (int64_t)mktime(&t) * 1000; "
649
+ f"if (_had_old) {{ ::setenv(\"TZ\", _old_tz.c_str(), 1); }} "
650
+ f"else {{ ::unsetenv(\"TZ\"); }} ::tzset(); "
651
+ f"}} "
652
+ f"_last_tz = _tz; _last_yr = _yr; _last_mo = _mo; _last_dy = _dy; _last_hr = _hr; _last_min = _min; _last_sc = _sc; "
653
+ f"_last_res = _res; "
654
+ f"return _res; "
655
+ f"}}()"
656
+ )
657
+ else:
658
+ args = [self._visit_expr(a) for a in node.args]
659
+ if len(args) >= 1:
660
+ if len(args) == 1:
661
+ return "0"
662
+ yr = args[0]
663
+ mo = args[1] if len(args) > 1 else "1"
664
+ dy = args[2] if len(args) > 2 else "1"
665
+ hr = args[3] if len(args) > 3 else "0"
666
+ mn = args[4] if len(args) > 4 else "0"
667
+ sc = args[5] if len(args) > 5 else "0"
668
+ return (
669
+ f"[&]() -> int64_t {{ "
670
+ f"int _yr = ({yr}); int _mo = ({mo}); int _dy = ({dy}); "
671
+ f"int _hr = ({hr}); int _min = ({mn}); int _sc = ({sc}); "
672
+ f"static thread_local int _last_yr = -1, _last_mo = -1, _last_dy = -1, _last_hr = -1, _last_min = -1, _last_sc = -1; "
673
+ f"static thread_local int64_t _last_res = -1; "
674
+ f"if (_last_res != -1 && _last_yr == _yr && _last_mo == _mo && _last_dy == _dy && _last_hr == _hr && _last_min == _min && _last_sc == _sc) {{ "
675
+ f"return _last_res; "
676
+ f"}} "
677
+ f"struct tm t = {{}}; "
678
+ f"t.tm_year = _yr - 1900; t.tm_mon = _mo - 1; "
679
+ f"t.tm_mday = _dy; t.tm_hour = _hr; t.tm_min = _min; t.tm_sec = _sc; "
680
+ f"int64_t _res = (int64_t)timegm(&t) * 1000; "
681
+ f"_last_yr = _yr; _last_mo = _mo; _last_dy = _dy; _last_hr = _hr; _last_min = _min; _last_sc = _sc; "
682
+ f"_last_res = _res; "
683
+ f"return _res; "
684
+ f"}}()"
685
+ )
686
+ return "0"
687
+
688
+ # barssince() — unsupported. Defensive: support_checker rejects bare
689
+ # barssince(...) with a hint to use ta.barssince(...). Reaching here
690
+ # means the checker was bypassed.
691
+ if func_name == "barssince" and namespace is None:
692
+ raise ValueError(
693
+ "codegen: bare barssince(...) is not supported — analyzer should "
694
+ "have rejected. Use ta.barssince(...)."
695
+ )
696
+
697
+ # Type cast functions: int(x), float(x), bool(x), string(x)
698
+ if func_name == "int" and namespace is None and node.args:
699
+ return f"(int)({self._visit_expr(node.args[0])})"
700
+ if func_name == "float" and namespace is None and node.args:
701
+ return f"(double)({self._visit_expr(node.args[0])})"
702
+ if func_name == "bool" and namespace is None and node.args:
703
+ return f"(bool)({self._visit_expr(node.args[0])})"
704
+ if func_name == "string" and namespace is None and node.args:
705
+ return f"std::to_string({self._visit_expr(node.args[0])})"
706
+
707
+ # ta.pivot_point_levels — free function, not a stateful indicator
708
+ if namespace == "ta" and func_name == "pivot_point_levels":
709
+ if node.kwargs:
710
+ args = _merge_kwargs(
711
+ node.args,
712
+ node.kwargs,
713
+ sigs.get_param_names("ta", "pivot_point_levels"),
714
+ self._visit_expr,
715
+ )
716
+ else:
717
+ args = [self._visit_expr(a) for a in node.args]
718
+ if len(args) >= 4:
719
+ return f'ta::pivot_point_levels({", ".join(args[:4])})'
720
+ if 1 <= len(args) <= 3:
721
+ # Pine overload (type, anchor, developing). Per Pine v6
722
+ # semantics, `developing=false` (the default) means the pivot
723
+ # is computed from the LAST CLOSED period's HLC. With
724
+ # `anchor=true` constant, the "period" is one bar, so we
725
+ # consume the PREVIOUS bar's HLC via `_s_high[1]`, etc. The
726
+ # analyzer registers high/low/close in `series_bar_fields` so
727
+ # those `Series<double>` members are guaranteed to exist.
728
+ # Previously we passed `current_bar_.high/low/close` which
729
+ # produced TV-shifted-by-one-bar values for every level.
730
+ return (
731
+ f"ta::pivot_point_levels({args[0]}, _s_high[1], "
732
+ f"_s_low[1], _s_close[1])"
733
+ )
734
+ return f'ta::pivot_point_levels({", ".join(args)})'
735
+
736
+ # Unknown ta.* calls — safe fallback
737
+ if namespace == "ta":
738
+ return f"na<double>() /* unsupported: ta.{func_name} */"
739
+
740
+ if namespace == "syminfo":
741
+ if func_name == "prefix":
742
+ return "_pf_derive_prefix(syminfo_.tickerid)"
743
+ if func_name == "ticker":
744
+ return "syminfo_.ticker"
745
+ return f"na<double>() /* unsupported: syminfo.{func_name} */"
746
+
747
+ # str.* fallback now handled by _visit_str_call above
748
+
749
+ # matrix.* calls
750
+ if namespace == "matrix":
751
+ if func_name == "new":
752
+ targs = self._template_args_from_call(node)
753
+ elem_spec = self._type_spec_from_hint_name(targs[0]) if targs else TypeSpec.primitive("float")
754
+ args_e = [self._visit_expr(a) for a in node.args]
755
+ rows = args_e[0] if args_e else "0"
756
+ cols = args_e[1] if len(args_e) > 1 else "0"
757
+ if elem_spec.kind == "primitive" and elem_spec.name == "float":
758
+ init = args_e[2] if len(args_e) > 2 else "0.0"
759
+ return f"PineMatrix::new_({rows}, {cols}, {init})"
760
+ cpp_t = self._type_spec_to_cpp(elem_spec)
761
+ init = args_e[2] if len(args_e) > 2 else self._default_for_spec(elem_spec)
762
+ return f"PineGenericMatrix<{cpp_t}>::new_({rows}, {cols}, {init})"
763
+ if func_name in MATRIX_METHODS and node.args:
764
+ from ..ast_nodes import Identifier as _Ident
765
+ if func_name in MATRIX_NUMERIC_ONLY:
766
+ if not isinstance(node.args[0], _Ident):
767
+ self._codegen_error(node, f"matrix.{func_name} receiver must be a variable reference")
768
+ recv_name = node.args[0].name
769
+ if recv_name not in self._matrix_specs:
770
+ self._codegen_error(node, f"matrix.{func_name}: receiver '{recv_name}' is not a known matrix variable")
771
+ self._check_matrix_method_allowed(func_name, self._matrix_specs[recv_name], node)
772
+ if func_name == "sort":
773
+ if isinstance(node.args[0], _Ident):
774
+ recv_name = node.args[0].name
775
+ if recv_name in self._matrix_specs:
776
+ self._check_matrix_method_allowed(func_name, self._matrix_specs[recv_name], node)
777
+ obj = self._visit_expr(node.args[0])
778
+ param_names = MATRIX_METHOD_KWARGS.get(func_name)
779
+ if param_names:
780
+ rest = _merge_kwargs(node.args[1:], node.kwargs, param_names, self._visit_expr)
781
+ else:
782
+ rest = [self._visit_expr(a) for a in node.args[1:]]
783
+ fn = MATRIX_METHODS[func_name]
784
+ try:
785
+ return fn(obj, rest)
786
+ except IndexError:
787
+ self._codegen_error(
788
+ node,
789
+ f"matrix.{func_name}: wrong number of arguments",
790
+ hint="Check Pine v6 matrix method signature (positional vs keyword).",
791
+ )
792
+ return "0.0"
793
+
794
+ # log.* calls (log.error, log.warning, log.info)
795
+ if namespace == "log":
796
+ log_funcs = {"info": "pine_log_info", "warning": "pine_log_warning", "error": "pine_log_error"}
797
+ if func_name in log_funcs:
798
+ log_args = [self._visit_expr(a) for a in node.args]
799
+ msg_arg = log_args[0] if log_args else '""'
800
+ return f'{log_funcs[func_name]}({msg_arg})'
801
+ return '"" /* unsupported log */'
802
+
803
+ # timeframe.* calls (e.g., timeframe.change) — not supported in single-TF backtest
804
+ if namespace == "timeframe":
805
+ if func_name == "change":
806
+ tf_arg = self._visit_expr(node.args[0]) if node.args else 'script_tf_'
807
+ return f'tf_change(prev_bar_timestamp_, current_bar_.timestamp, {tf_arg})'
808
+ if func_name == "in_seconds":
809
+ tf_arg = self._visit_expr(node.args[0]) if node.args else 'script_tf_'
810
+ return f'tf_to_seconds({tf_arg})'
811
+ # Defensive: support_checker.NOT_YET_FUNC should already have rejected
812
+ # any unhandled timeframe.* call. Reaching here implies the checker was
813
+ # bypassed.
814
+ raise ValueError(
815
+ f"codegen: unhandled timeframe.{func_name} — analyzer should have "
816
+ f"rejected this. Either add a handler above or extend NOT_YET_FUNC."
817
+ )
818
+
819
+ # UDT constructor: TypeName.new(field=val, ...)
820
+ if namespace in self._udt_defs and func_name == "new":
821
+ fields = self._udt_defs[namespace]
822
+ field_names = [f.name for f in fields]
823
+ init_vals = {}
824
+ for i, a in enumerate(node.args):
825
+ if i < len(field_names):
826
+ init_vals[field_names[i]] = self._visit_expr(a)
827
+ for k, v in node.kwargs.items():
828
+ init_vals[k] = self._visit_expr(v)
829
+ field_inits = []
830
+ field_specs = self._udt_field_type_specs.get(namespace, {})
831
+ for f in fields:
832
+ val = None
833
+ if f.name in init_vals:
834
+ val = init_vals[f.name]
835
+ elif f.default:
836
+ val = self._visit_expr(f.default)
837
+ if val is not None:
838
+ # Fix narrowing: cast na<double>() to correct type for int fields
839
+ f_cpp_type = self._type_spec_to_cpp(field_specs.get(f.name) or self._type_spec_from_hint_name(f.type_name))
840
+ if f_cpp_type == "int" and "na<double>" in val:
841
+ val = val.replace("na<double>()", "0")
842
+ field_inits.append(f".{f.name} = {val}")
843
+ return f"{namespace}{{{', '.join(field_inits)}}}"
844
+
845
+ # UDT copy: TypeName.copy(obj)
846
+ if namespace in self._udt_defs and func_name == "copy":
847
+ if node.args:
848
+ return self._visit_expr(node.args[0])
849
+ return f"{namespace}{{}}"
850
+
851
+ # Safety net before the generic emitter. Every builtin namespace and
852
+ # bare builtin that codegen knows how to emit has been dispatched (and
853
+ # returned) above; SKIP_NAMESPACES / SKIP_FUNC_NAMES returned "0";
854
+ # user-defined functions live in ``self._func_names`` and UDT
855
+ # constructors/copies were handled via ``self._udt_defs``. Anything
856
+ # still here would be written out verbatim — ``made_up(...)`` or
857
+ # ``qux::frobnicate(...)`` — i.e. an *undeclared C++ symbol*. That is a
858
+ # silent miscompile: the support checker did not reject it, so the user
859
+ # would otherwise only see a cryptic g++ error pointing at generated
860
+ # C++ instead of their Pine line. Reject loudly with the offending
861
+ # node's location. (Note: any script that reached this branch already
862
+ # failed to compile, so the all-green corpus never exercises it — this
863
+ # only converts garbage output into a clean diagnostic.)
864
+ # ``func_name is None`` means the callee is a complex expression the
865
+ # resolver does not reduce to a simple ``name`` / ``ns.name`` — e.g. a
866
+ # chained method call ``m.transpose().copy()`` whose receiver is itself
867
+ # a FuncCall. Those are handled by the existing generic/chained logic
868
+ # below; do not treat them as unknown builtins.
869
+ if namespace is None and func_name is not None:
870
+ if func_name not in self._func_names:
871
+ self._codegen_error(
872
+ node,
873
+ f"Unknown function '{func_name}(...)' — not a PineForge "
874
+ f"builtin or a user-defined function.",
875
+ hint="Check the spelling; the function may not be supported "
876
+ "by PineForge, or needs its namespace (e.g. math./str.).",
877
+ )
878
+ elif namespace is not None and namespace not in self._udt_defs:
879
+ self._codegen_error(
880
+ node,
881
+ f"Unknown call '{namespace}.{func_name}(...)' — '{namespace}' is "
882
+ f"not a PineForge-supported namespace or a user-defined type.",
883
+ hint="Check the spelling; this namespace may not be supported "
884
+ "by PineForge.",
885
+ )
886
+
887
+ # Generic function call (user-defined or unknown)
888
+ # Determine which params are series (need Series<double> arg, not scalar)
889
+ _func_series_param_indices: set[int] = set()
890
+ fi_lookup = self._func_info_map.get(func_name)
891
+ if fi_lookup and fi_lookup.node:
892
+ func_sv = self.ctx.func_series_vars.get(fi_lookup.name, set())
893
+ for p_idx, p_name in enumerate(fi_lookup.node.params):
894
+ if p_name in func_sv:
895
+ _func_series_param_indices.add(p_idx)
896
+
897
+ def _visit_arg_for_series(arg_node, arg_idx):
898
+ """Visit a function argument, returning Series ref for series params."""
899
+ if arg_idx in _func_series_param_indices and isinstance(arg_node, Identifier):
900
+ aname = arg_node.name
901
+ # Bar field: pass _s_close instead of current_bar_.close
902
+ if aname in BAR_FIELDS or aname in BAR_SERIES_PUSH:
903
+ return f"_s_{aname}"
904
+ # Series var: pass the Series object directly
905
+ if aname in self.ctx.series_vars:
906
+ safe = self._safe_name(aname)
907
+ if self._active_var_remap and safe in self._active_var_remap:
908
+ safe = self._active_var_remap[safe]
909
+ return safe
910
+ return self._visit_expr(arg_node)
911
+
912
+ if node.kwargs:
913
+ # Try to resolve kwargs using FuncInfo params for user-defined functions
914
+ fi = self._func_info_map.get(func_name)
915
+ if fi and fi.node and fi.node.params:
916
+ param_names = list(fi.node.params) # params is list[str]
917
+ # Merge kwargs then visit with series awareness
918
+ merged = _merge_kwargs(node.args, node.kwargs, param_names, lambda a: a)
919
+ all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(merged)]
920
+ elif sigs.is_intrinsic_function(namespace, func_name):
921
+ # Known intrinsic — use signature registry for kwargs resolution
922
+ param_names = sigs.get_param_names(namespace, func_name)
923
+ all_args = _merge_kwargs(node.args, node.kwargs, param_names, self._visit_expr)
924
+ else:
925
+ # Unknown function: positional args + kwargs values as fallback
926
+ all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
927
+ all_args.extend(self._visit_expr(v) for v in node.kwargs.values())
928
+ else:
929
+ all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
930
+ # Default args (parser does not store defaults): isInSession(sess, res = timeframe.period)
931
+ if namespace is None and func_name in self._func_names:
932
+ fi = self._func_info_map.get(func_name)
933
+ if fi and fi.node and fi.name == "isInSession" and len(fi.node.params) >= 2 and len(all_args) == 1:
934
+ # Mirror Pine default `timeframe.period` instead of hard-coding 15m.
935
+ all_args.append("script_tf_")
936
+ prefix = f"{namespace}::" if namespace else ""
937
+ # Use safe name for user-defined functions to avoid member name collision
938
+ emit_name = self._func_safe_name(func_name) if func_name in self._func_names else func_name
939
+ # Per-call-site variant: if this function has TA/series calls, call the correct variant
940
+ cs_info = self.ctx.func_call_cs_map.get(id(node))
941
+ if self._active_call_site_idx is not None and cs_info is not None:
942
+ # Inside a per-call-site variant: override the cs_map index with
943
+ # the parent's active call-site index. This ensures sub-functions
944
+ # called from ma_cs6() use their _cs6 variant, not _cs0.
945
+ fname, _ = cs_info
946
+ emit_name = f"{self._func_safe_name(fname)}_cs{self._active_call_site_idx}"
947
+ elif cs_info is not None:
948
+ fname, cs_idx = cs_info
949
+ emit_name = f"{self._func_safe_name(fname)}_cs{cs_idx}"
950
+ elif (self._active_call_site_idx is not None
951
+ and func_name in self._func_names
952
+ and self.ctx.func_call_site_counts.get(func_name, 0) > 1):
953
+ # Inside a per-call-site variant: propagate call-site index to
954
+ # sub-functions that also have variants (for state isolation)
955
+ emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}"
956
+ return f"{prefix}{emit_name}({', '.join(all_args)})"
957
+
958
+ def _visit_fixnan(self, node: FuncCall) -> str:
959
+ """Emit fixnan with persistent state member."""
960
+ self._fixnan_counter += 1
961
+ member = f"_prev_fixnan_{self._fixnan_counter}"
962
+ x = self._visit_expr(node.args[0])
963
+ return f"(is_na({x}) ? {member} : ({member} = {x}))"
964
+
965
+ def _visit_strategy_call(self, func_name: str, node: FuncCall) -> str:
966
+ if func_name in ("convert_to_account", "convert_to_symbol"):
967
+ p = self._resolve_func_args(node, f"strategy.{func_name}")
968
+ v = self._visit_expr(p.get("value")) if p.get("value") is not None else "0.0"
969
+ return f"({v})"
970
+ if func_name == "default_entry_qty":
971
+ p = self._resolve_func_args(node, "strategy.default_entry_qty")
972
+ fp = self._visit_expr(p.get("fill_price")) if p.get("fill_price") is not None else "0.0"
973
+ return f"calc_qty({fp})"
974
+
975
+ if func_name == "entry":
976
+ p = self._resolve_func_args(node, "strategy.entry")
977
+ entry_id = self._visit_expr(p.get("id")) if "id" in p else '""'
978
+ direction = self._visit_expr(p.get("direction")) if "direction" in p else "true"
979
+ stop = p.get("stop")
980
+ limit = p.get("limit")
981
+ qty = p.get("qty")
982
+ comment = p.get("comment")
983
+ oca_name = p.get("oca_name")
984
+ oca_type = p.get("oca_type")
985
+ qty_type = p.get("qty_type")
986
+ comment_val = self._visit_expr(comment) if comment else '""'
987
+ oca_name_val = self._visit_expr(oca_name) if oca_name else '""'
988
+ oca_type_val = self._visit_expr(oca_type) if oca_type else "0"
989
+ qty_type_val = self._visit_expr(qty_type) if qty_type else "-1"
990
+ qty_val = self._visit_expr(qty) if qty else "na<double>()"
991
+ if stop is not None or limit is not None or qty is not None or oca_name is not None or oca_type is not None or qty_type is not None:
992
+ limit_val = self._visit_expr(limit) if limit else "na<double>()"
993
+ stop_val = self._visit_expr(stop) if stop else "na<double>()"
994
+ # pineforge-engine v0.2 dropped the vestigial `market_price`
995
+ # third positional from `BacktestEngine::strategy_entry`
996
+ # (the runtime never read it; fill price always came from
997
+ # current_bar_.close inside the function body). Codegen now
998
+ # matches the new signature: (id, direction, limit, stop,
999
+ # qty, comment, oca_name, oca_type, qty_type).
1000
+ return f"strategy_entry({entry_id}, {direction}, {limit_val}, {stop_val}, {qty_val}, {comment_val}, {oca_name_val}, {oca_type_val}, {qty_type_val})"
1001
+ return f"strategy_entry({entry_id}, {direction}, na<double>(), na<double>(), na<double>(), {comment_val})"
1002
+
1003
+ if func_name == "close":
1004
+ p = self._resolve_func_args(node, "strategy.close")
1005
+ close_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1006
+ comment = self._visit_expr(p.get("comment")) if p.get("comment") is not None else '""'
1007
+ qty = self._visit_expr(p.get("qty")) if p.get("qty") is not None else "na<double>()"
1008
+ qty_pct = self._visit_expr(p.get("qty_percent")) if p.get("qty_percent") is not None else "na<double>()"
1009
+ immediately = self._visit_expr(p.get("immediately")) if p.get("immediately") is not None else "false"
1010
+ return f"strategy_close({close_id}, {comment}, {qty}, {qty_pct}, {immediately})"
1011
+
1012
+ if func_name == "close_all":
1013
+ return "strategy_close_all()"
1014
+
1015
+ if func_name == "exit":
1016
+ p = self._resolve_func_args(node, "strategy.exit")
1017
+ exit_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1018
+ from_id = self._visit_expr(p.get("from_entry")) if "from_entry" in p else '""'
1019
+
1020
+ limit_n = p.get("limit")
1021
+ stop_n = p.get("stop")
1022
+ profit_n = p.get("profit")
1023
+ loss_n = p.get("loss")
1024
+ trail_pts_n = p.get("trail_points")
1025
+ trail_off_n = p.get("trail_offset")
1026
+ trail_pr_n = p.get("trail_price")
1027
+ qty_pct_n = p.get("qty_percent")
1028
+ qty_n = p.get("qty")
1029
+ comment_n = p.get("comment")
1030
+ oca_name_n = p.get("oca_name")
1031
+
1032
+ has_price_exit = any(x is not None for x in
1033
+ [limit_n, stop_n, profit_n, loss_n,
1034
+ trail_pts_n, trail_off_n, trail_pr_n])
1035
+ if has_price_exit:
1036
+ limit_val = self._visit_expr(limit_n) if limit_n else "na<double>()"
1037
+ stop_val = self._visit_expr(stop_n) if stop_n else "na<double>()"
1038
+ trail_pts = self._visit_expr(trail_pts_n) if trail_pts_n else "na<double>()"
1039
+ trail_off = self._visit_expr(trail_off_n) if trail_off_n else "na<double>()"
1040
+ trail_pr = self._visit_expr(trail_pr_n) if trail_pr_n else "na<double>()"
1041
+ qty_pct = self._visit_expr(qty_pct_n) if qty_pct_n else "100.0"
1042
+ qty_val = self._visit_expr(qty_n) if qty_n else "na<double>()"
1043
+ comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1044
+ oca_val = self._visit_expr(oca_name_n) if oca_name_n is not None else '""'
1045
+
1046
+ if profit_n and not limit_n:
1047
+ ticks = self._visit_expr(profit_n)
1048
+ limit_val = f"(position_entry_price_ + (signed_position_size() > 0 ? 1.0 : -1.0) * ({ticks}) * syminfo_mintick_)"
1049
+ if loss_n and not stop_n:
1050
+ ticks = self._visit_expr(loss_n)
1051
+ stop_val = f"(position_entry_price_ - (signed_position_size() > 0 ? 1.0 : -1.0) * ({ticks}) * syminfo_mintick_)"
1052
+
1053
+ return (f"strategy_exit({exit_id}, {from_id}, {limit_val}, {stop_val}, "
1054
+ f"{trail_pts}, {trail_off}, {trail_pr}, {qty_pct}, {comment}, "
1055
+ f"{qty_val}, {oca_val})")
1056
+ close_comment = self._visit_expr(comment_n) if comment_n is not None else '""'
1057
+ return f"strategy_close({exit_id}, {close_comment})"
1058
+
1059
+ if func_name == "cancel":
1060
+ p = self._resolve_func_args(node, "strategy.close") # same shape: id first
1061
+ cancel_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1062
+ return f"strategy_cancel({cancel_id})"
1063
+
1064
+ if func_name == "cancel_all":
1065
+ return "strategy_cancel_all()"
1066
+
1067
+ if func_name == "order":
1068
+ p = self._resolve_func_args(node, "strategy.order")
1069
+ order_id = self._visit_expr(p.get("id")) if "id" in p else '""'
1070
+ direction = self._visit_expr(p.get("direction")) if "direction" in p else "true"
1071
+ qty = self._visit_expr(p.get("qty")) if "qty" in p else "0"
1072
+ limit_arg = self._visit_expr(p.get("limit")) if "limit" in p else "na<double>()"
1073
+ stop_arg = self._visit_expr(p.get("stop")) if "stop" in p else "na<double>()"
1074
+ oca_name = self._visit_expr(p.get("oca_name")) if "oca_name" in p else '""'
1075
+ oca_type = self._visit_expr(p.get("oca_type")) if "oca_type" in p else "0"
1076
+ return f"strategy_order({order_id}, {direction}, {qty}, {limit_arg}, {stop_arg}, {oca_name}, {oca_type})"
1077
+
1078
+ if func_name == "risk":
1079
+ return "/* skip */"
1080
+
1081
+ # strategy.closedtrades.*(idx) / strategy.opentrades.*(idx)
1082
+ # These come through as func_name="profit" etc. with nested callee
1083
+ if isinstance(node.callee, MemberAccess):
1084
+ inner = node.callee.object
1085
+ if isinstance(inner, MemberAccess) and inner.member in ("closedtrades", "opentrades"):
1086
+ idx = self._visit_expr(node.args[0]) if node.args else "0"
1087
+ is_open = inner.member == "opentrades"
1088
+ # Open trades have no exit metadata in Pine
1089
+ if is_open and func_name in (
1090
+ "exit_price", "exit_time", "exit_comment", "exit_id", "exit_bar_index",
1091
+ ):
1092
+ if func_name == "exit_bar_index":
1093
+ return "na<int>()"
1094
+ if func_name == "exit_time":
1095
+ return "0"
1096
+ if func_name == "exit_price":
1097
+ return "na<double>()"
1098
+ if func_name in ("exit_comment", "exit_id"):
1099
+ return "std::string()"
1100
+
1101
+ prefix = "open_trade_" if is_open else "closed_trade_"
1102
+ suffix_map = {
1103
+ "profit": "profit",
1104
+ "profit_percent": "profit_percent",
1105
+ "commission": "commission",
1106
+ "direction": "direction",
1107
+ "entry_bar_index": "entry_bar_index",
1108
+ "exit_bar_index": "exit_bar_index",
1109
+ "entry_comment": "entry_comment",
1110
+ "exit_comment": "exit_comment",
1111
+ "entry_id": "entry_id",
1112
+ "exit_id": "exit_id",
1113
+ "entry_price": "entry_price",
1114
+ "exit_price": "exit_price",
1115
+ "entry_time": "entry_time",
1116
+ "exit_time": "exit_time",
1117
+ "size": "size",
1118
+ "max_runup": "max_runup",
1119
+ "max_runup_percent": "max_runup_percent",
1120
+ "max_drawdown": "max_drawdown",
1121
+ "max_drawdown_percent": "max_drawdown_percent",
1122
+ }
1123
+ fn = suffix_map.get(func_name, "profit")
1124
+ return f"{prefix}{fn}({idx})"
1125
+
1126
+ # Defensive: support_checker rejects unknown strategy.* calls (name not
1127
+ # in sigs.STRATEGY_FUNCTIONS) and unknown strategy.closedtrades.* /
1128
+ # strategy.opentrades.* accessors (not in the side-specific accessor
1129
+ # whitelists). Reaching here means the checker was bypassed or drifted.
1130
+ raise ValueError(
1131
+ f"codegen: unhandled strategy.{func_name}(...) — analyzer should "
1132
+ f"have rejected. Add a handler above or extend STRATEGY_FUNCTIONS."
1133
+ )
1134
+
1135
+ def _visit_color_call(self, func_name: str, node) -> str:
1136
+ """Emit color.* calls as integer representations."""
1137
+ args = [self._visit_expr(a) for a in node.args]
1138
+ if func_name == "new":
1139
+ if len(args) >= 2:
1140
+ return f'pine_color::new_color({args[0]}, (int)({args[1]}))'
1141
+ return "0"
1142
+ if func_name in ("r", "g", "b", "t"):
1143
+ if args:
1144
+ return f'pine_color::{func_name}({args[0]})'
1145
+ return "0"
1146
+ if func_name == "rgb":
1147
+ if len(args) >= 4:
1148
+ return f"pine_color::new_color(((int64_t)({args[0]}) << 16 | (int64_t)({args[1]}) << 8 | (int64_t)({args[2]})), (int)({args[3]}))"
1149
+ elif len(args) >= 3:
1150
+ return f"pine_color::new_color(((int64_t)({args[0]}) << 16 | (int64_t)({args[1]}) << 8 | (int64_t)({args[2]})), 0)"
1151
+ return "0"
1152
+ if func_name == "from_gradient":
1153
+ return "0"
1154
+ return "0"
1155
+
1156
+ def _visit_str_call(self, func_name: str, node) -> str:
1157
+ args = _merge_kwargs(node.args, node.kwargs,
1158
+ sigs.get_param_names("str", func_name), self._visit_expr)
1159
+
1160
+ if func_name == "tostring":
1161
+ # Pine: str.tostring(enumVar) → field title / IANA string, not the int index
1162
+ val_arg = node.args[0] if node.args else node.kwargs.get("value")
1163
+ if isinstance(val_arg, Identifier):
1164
+ sym = self.ctx.symbols.resolve(val_arg.name)
1165
+ if sym is not None and sym.enum_type_name:
1166
+ et = sym.enum_type_name
1167
+ tbl = self._enum_member_strings.get(et)
1168
+ if tbl:
1169
+ var = self._safe_name(val_arg.name)
1170
+ n = len(tbl)
1171
+ return (
1172
+ f"pine_enum_str_at({et}_str_values, {n}, {var})"
1173
+ )
1174
+ if len(args) >= 2:
1175
+ return f"pine_str_tostring({args[0]}, {args[1]}, syminfo_mintick_)"
1176
+ if len(args) >= 1:
1177
+ return f"std::to_string({args[0]})"
1178
+ return 'std::string("")'
1179
+
1180
+ if func_name == "substring":
1181
+ if len(args) == 3:
1182
+ return f"{args[0]}.substr({args[1]}, {args[2]} - {args[1]})"
1183
+ elif len(args) == 2:
1184
+ return f"{args[0]}.substr({args[1]})"
1185
+ return 'std::string("")'
1186
+
1187
+ if func_name == "format":
1188
+ # str.format is variadic: signature has only ``formatStr``; the
1189
+ # remaining args are placeholder substitutions. The runtime
1190
+ # ``pine_str_format(fmt, vector<string>)`` requires every arg
1191
+ # already converted to ``std::string``. We previously gated the
1192
+ # ``std::to_string`` wrap on a source-text-prefix heuristic
1193
+ # (``"`` / ``std::string`` / ``pine_str``), which mis-classified
1194
+ # any string-typed bare identifier or string-returning helper
1195
+ # call (e.g. ``str.tostring(x)`` bound to a variable). The type
1196
+ # check below uses the analyzer's inferred PineType instead so
1197
+ # ``std::string`` args pass through unchanged.
1198
+ if node.args:
1199
+ fmt_arg = self._visit_expr(node.args[0])
1200
+ rest = []
1201
+ for orig in node.args[1:]:
1202
+ visited = self._visit_expr(orig)
1203
+ inferred = self._infer_type(orig)
1204
+ if inferred == "std::string":
1205
+ rest.append(visited)
1206
+ continue
1207
+ # Booleans render as 0/1 via std::to_string; force the
1208
+ # TV-style "true"/"false" output so backtest logs and
1209
+ # alert messages line up with the TradingView side.
1210
+ if inferred == "bool":
1211
+ rest.append(
1212
+ f'({visited} ? std::string("true") : std::string("false"))'
1213
+ )
1214
+ continue
1215
+ rest.append(f'std::to_string({visited})')
1216
+ if rest:
1217
+ vec = "{" + ", ".join(rest) + "}"
1218
+ return f'pine_str_format({fmt_arg}, {vec})'
1219
+ return fmt_arg
1220
+ return 'std::string("")'
1221
+
1222
+ if func_name == "format_time":
1223
+ ts = args[0] if args else "0"
1224
+ fmt = args[1] if len(args) > 1 else '"yyyy-MM-dd"'
1225
+ tz = args[2] if len(args) > 2 else '"UTC"'
1226
+ return f'pine_str_format_time({ts}, {fmt}, {tz})'
1227
+
1228
+ if func_name == "replace":
1229
+ if len(args) >= 3:
1230
+ return f'[&](){{ std::string s={args[0]}; auto p=s.find({args[1]}); if(p!=std::string::npos) s.replace(p,{args[1]}.length(),{args[2]}); return s; }}()'
1231
+ return 'std::string("")'
1232
+
1233
+ if func_name in STR_FUNC_MAP and STR_FUNC_MAP[func_name] is not None:
1234
+ return STR_FUNC_MAP[func_name](args)
1235
+
1236
+ return f'std::string("") /* unsupported: str.{func_name} */'
1237
+
1238
+ def _visit_math_call(self, func_name: str, node: FuncCall) -> str:
1239
+ args = _merge_kwargs(node.args, node.kwargs, sigs.get_param_names("math", func_name), self._visit_expr)
1240
+ # Handle special cases first
1241
+ if func_name == "round" and len(args) == 2:
1242
+ return f"(std::round({args[0]} * std::pow(10.0, {args[1]})) / std::pow(10.0, {args[1]}))"
1243
+ if func_name == "round_to_mintick":
1244
+ x = args[0] if args else "0.0"
1245
+ return f"(std::round({x} / syminfo_mintick_) * syminfo_mintick_)"
1246
+ if func_name == "todegrees":
1247
+ x = args[0] if args else "0.0"
1248
+ return f"({x} * 180.0 / M_PI)"
1249
+ if func_name == "toradians":
1250
+ x = args[0] if args else "0.0"
1251
+ return f"({x} * M_PI / 180.0)"
1252
+ if func_name == "random":
1253
+ lo = args[0] if len(args) > 0 else "0.0"
1254
+ hi = args[1] if len(args) > 1 else "1.0"
1255
+ seed = args[2] if len(args) > 2 else "0"
1256
+ call_site = self._random_call_counter
1257
+ self._random_call_counter += 1
1258
+ return f"pine_random({lo}, {call_site}u, {hi}, (uint32_t)({seed}), bar_index_)"
1259
+ if func_name == "avg" and len(args) > 2:
1260
+ sum_expr = " + ".join(f"(double)({a})" for a in args)
1261
+ return f"(({sum_expr}) / {len(args)}.0)"
1262
+ if func_name == "max" and len(args) > 2:
1263
+ result = f"std::max((double)({args[0]}), (double)({args[1]}))"
1264
+ for a in args[2:]:
1265
+ result = f"std::max({result}, (double)({a}))"
1266
+ return result
1267
+ if func_name == "min" and len(args) > 2:
1268
+ result = f"std::min((double)({args[0]}), (double)({args[1]}))"
1269
+ for a in args[2:]:
1270
+ result = f"std::min({result}, (double)({a}))"
1271
+ return result
1272
+ if func_name in MATH_FUNC_MAP:
1273
+ mapped = MATH_FUNC_MAP[func_name]
1274
+ if "{0}" in mapped:
1275
+ return mapped.format(*args)
1276
+ # std::min/std::max require same types — cast to double
1277
+ if func_name in ("min", "max") and len(args) == 2:
1278
+ return f"{mapped}((double)({args[0]}), (double)({args[1]}))"
1279
+ return f"{mapped}({', '.join(args)})"
1280
+ # Unknown math.* — safe fallback
1281
+ return f"0.0 /* unsupported: math.{func_name} */"
1282
+
1283
+ # ------------------------------------------------------------------
1284
+ # Arg/kwarg resolution (PineScript parameter signatures)
1285
+ # ------------------------------------------------------------------
1286
+
1287
+ def _resolve_func_args(self, node: FuncCall, sig_key: str) -> dict:
1288
+ """Merge positional args and kwargs into a dict keyed by parameter name.
1289
+
1290
+ Uses the PineScript parameter ordering from signatures registry.
1291
+ """
1292
+ # sig_key is like "strategy.entry" -> namespace="strategy", func_name="entry"
1293
+ parts = sig_key.split(".", 1)
1294
+ if len(parts) == 2:
1295
+ param_names = sigs.get_param_names(parts[0], parts[1]) or []
1296
+ else:
1297
+ param_names = sigs.get_param_names(None, sig_key) or []
1298
+ result: dict = {}
1299
+ # Map positional args by parameter name
1300
+ for i, arg in enumerate(node.args):
1301
+ if i < len(param_names):
1302
+ result[param_names[i]] = arg
1303
+ # kwargs override positional
1304
+ result.update(node.kwargs)
1305
+ return result