@pineforge/codegen-pyodide 0.10.4 → 1.0.0-rc.1

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 (50) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +754 -76
  7. package/pineforge_codegen/analyzer/call_handlers.py +260 -40
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +896 -156
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +946 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1188 -100
  25. package/pineforge_codegen/codegen/tables.py +193 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +1882 -78
  28. package/pineforge_codegen/codegen/visit_call.py +920 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +738 -57
  30. package/pineforge_codegen/codegen/visit_stmt.py +595 -49
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  47. package/release.json +2 -2
  48. package/tables.json +23 -21
  49. package/transpile.worker.mjs +24 -16
  50. package/pineforge_codegen-0.10.4.tar.gz +0 -0
@@ -0,0 +1,131 @@
1
+ """Bounded numeric folding for constructor expressions, without Python execution.
2
+
3
+ ``ast`` only parses syntax here. No expression-supplied object, attribute or
4
+ callable is resolved: even ``math.sin`` is a fixed spelling in a local table.
5
+ Unsupported or non-finite expressions remain on the existing runtime/error path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import math
12
+ import operator
13
+ from collections.abc import Mapping
14
+
15
+
16
+ _BINARY = {
17
+ ast.Add: operator.add,
18
+ ast.Sub: operator.sub,
19
+ ast.Mult: operator.mul,
20
+ ast.Div: operator.truediv,
21
+ ast.Mod: operator.mod,
22
+ }
23
+ _UNARY = {ast.UAdd: operator.pos, ast.USub: operator.neg}
24
+ _FUNCTIONS = {
25
+ "abs": abs,
26
+ "round": round,
27
+ "math.abs": abs,
28
+ # Keep the existing folder's rounding behavior; this repair does not
29
+ # change numeric semantics to match a different rounding convention.
30
+ "math.round": round,
31
+ # sqrt/ceil/floor previously took a failed import rewrite and did not
32
+ # fold. Keep that domain unchanged: newly admitting sqrt under Python's
33
+ # round, for example, can disagree with the existing C++ runtime round.
34
+ "math.pow": math.pow,
35
+ "math.exp": math.exp,
36
+ "math.log": math.log,
37
+ "math.log10": math.log10,
38
+ "math.sin": math.sin,
39
+ "math.cos": math.cos,
40
+ "math.tan": math.tan,
41
+ "math.asin": math.asin,
42
+ "math.acos": math.acos,
43
+ "math.atan": math.atan,
44
+ }
45
+ _CONSTANTS = {"math.pi": math.pi, "math.e": math.e}
46
+
47
+ # Folding is optional. Bound parser work, tree traversal and integer growth;
48
+ # rejection retains the original expression for the caller's existing guard.
49
+ _MAX_SOURCE_LENGTH = 8192
50
+ _MAX_NODES = 512
51
+ _MAX_DEPTH = 64
52
+ _MAX_INTEGER_BITS = 1024
53
+ _MAX_ROUND_DIGITS = 1024
54
+
55
+
56
+ def _numeric(value: object) -> int | float | bool:
57
+ if type(value) is bool:
58
+ return value
59
+ if type(value) is int and value.bit_length() <= _MAX_INTEGER_BITS:
60
+ return value
61
+ if type(value) is float and math.isfinite(value):
62
+ return value
63
+ raise ValueError("not a bounded numeric constant")
64
+
65
+
66
+ def _spelling(node: ast.AST) -> str:
67
+ if isinstance(node, ast.Name):
68
+ return node.id
69
+ if (isinstance(node, ast.Attribute)
70
+ and isinstance(node.value, ast.Name)
71
+ and node.value.id == "math"):
72
+ return f"math.{node.attr}"
73
+ raise ValueError("not an allowed numeric spelling")
74
+
75
+
76
+ def fold_numeric_expression(
77
+ expression: str, known_values: Mapping[str, object],
78
+ ) -> int | float | bool | None:
79
+ """Return a numeric constant, or None when the syntax cannot be folded.
80
+
81
+ Names resolve only to caller-provided primitive numbers. Calls resolve
82
+ only to the fixed functions above and accept positional numeric arguments.
83
+ Attribute chains, indexing, containers, comprehensions, lambda expressions,
84
+ arbitrary call targets and keyword/unpacked arguments are never evaluated.
85
+ """
86
+ if len(expression) > _MAX_SOURCE_LENGTH:
87
+ return None
88
+
89
+ def visit(node: ast.AST, depth: int = 0) -> int | float | bool:
90
+ if depth > _MAX_DEPTH:
91
+ raise ValueError("numeric expression is too deep")
92
+ if isinstance(node, ast.Constant):
93
+ return _numeric(node.value)
94
+ if isinstance(node, ast.Name):
95
+ return _numeric(known_values[node.id])
96
+ if isinstance(node, ast.Attribute):
97
+ return _CONSTANTS[_spelling(node)]
98
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY:
99
+ return _numeric(_UNARY[type(node.op)](visit(node.operand, depth + 1)))
100
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINARY:
101
+ return _numeric(_BINARY[type(node.op)](
102
+ visit(node.left, depth + 1), visit(node.right, depth + 1),
103
+ ))
104
+ if isinstance(node, ast.Call) and not node.keywords:
105
+ spelling = _spelling(node.func)
106
+ if spelling.split(".", 1)[0] in known_values:
107
+ raise ValueError("numeric function name is shadowed")
108
+ function = _FUNCTIONS[spelling]
109
+ args = [visit(arg, depth + 1) for arg in node.args]
110
+ if spelling in ("round", "math.round"):
111
+ # CPython's integer round builds 10**(-ndigits). A small
112
+ # integer operand can still request enormous work, so bound
113
+ # this argument BEFORE dispatch, not only the returned value.
114
+ if not 1 <= len(args) <= 2:
115
+ raise ValueError("unsupported round argument count")
116
+ if len(args) == 2 and (
117
+ type(args[1]) not in (int, bool)
118
+ or abs(args[1]) > _MAX_ROUND_DIGITS
119
+ ):
120
+ raise ValueError("round precision is not bounded")
121
+ return _numeric(function(*args))
122
+ raise ValueError("not numeric expression syntax")
123
+
124
+ try:
125
+ tree = ast.parse(expression, mode="eval")
126
+ if sum(1 for _ in ast.walk(tree)) > _MAX_NODES:
127
+ return None
128
+ return visit(tree.body)
129
+ except (SyntaxError, ValueError, TypeError, KeyError, ArithmeticError,
130
+ RecursionError):
131
+ return None
@@ -19,6 +19,8 @@ reads only attributes established by ``CodeGen.__init__`` (``_uses_drawing``,
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
+ import re
23
+
22
24
  from ..ast_nodes import FuncCall, Identifier, MemberAccess
23
25
  from ..symbols import method_receiver_type_name
24
26
  from .tables import (
@@ -30,6 +32,74 @@ from .tables import (
30
32
 
31
33
  _MAP_VOID_METHODS = frozenset({"clear", "put_all"})
32
34
 
35
+ # Builtin calls Pine types as void, each lowering to a void C++ statement. A
36
+ # user function or selection arm ending in one returns no value, so it must be
37
+ # emitted as a statement, never as ``return strategy_exit(...)``.
38
+ _VOID_BUILTIN_CALLS = frozenset({
39
+ ("strategy", "entry"), ("strategy", "order"), ("strategy", "exit"),
40
+ ("strategy", "close"), ("strategy", "close_all"),
41
+ ("strategy", "cancel"), ("strategy", "cancel_all"),
42
+ ("log", "info"), ("log", "warning"), ("log", "error"),
43
+ ("runtime", "error"),
44
+ })
45
+
46
+ # TradingView's drawing lifetime, emitted once per drawing TU after
47
+ # ``using namespace pineforge;``. The engine's arena keeps a deleted record
48
+ # readable and evicts at exactly the cap; TradingView does neither (lane
49
+ # CG-W9-MISC tapes, tests/fixtures/drawing_lifetime). The generated arenas are
50
+ # therefore unbounded (``_PF_DRAWING_UNBOUNDED``), every getter and setter goes
51
+ # through the liveness check below, and ``_pf_collect_<kind>s_`` (a member of
52
+ # the strategy, which knows its variables) runs the collection.
53
+ DRAWING_LIFETIME_CPP = """\
54
+ // TradingView's drawing lifetime. A deleted or collected drawing reads like a
55
+ // na handle: every getter returns na, every setter does nothing and na() is
56
+ // true. A new line, box or label that makes its kind's live count reach
57
+ // max_<kind>_count + 6 deletes the oldest drawings of that kind that no var,
58
+ // varip or history-read variable holds, until max_<kind>_count remain; one
59
+ // held only by an array, a map, an object field, a local or a plain non-var
60
+ // variable is collectable. Linefills are never collected.
61
+ constexpr int _PF_DRAWING_UNBOUNDED = 2147483647;
62
+ template <class T> struct _PFDrawingArg { using type = T; };
63
+ template <class R, class Rec, class H, class... A>
64
+ inline R _pf_drawing_get(R (*get)(DrawingArena<Rec>&, H, A...),
65
+ typename _PFDrawingArg<DrawingArena<Rec>&>::type arena,
66
+ typename _PFDrawingArg<H>::type h,
67
+ typename _PFDrawingArg<A>::type... args) {
68
+ return arena.alive(h.id) ? get(arena, h, args...) : na<R>();
69
+ }
70
+ template <class Rec, class H, class... A>
71
+ inline void _pf_drawing_set(void (*set)(DrawingArena<Rec>&, H, A...),
72
+ typename _PFDrawingArg<DrawingArena<Rec>&>::type arena,
73
+ typename _PFDrawingArg<H>::type h,
74
+ typename _PFDrawingArg<A>::type... args) {
75
+ if (arena.alive(h.id)) set(arena, h, args...);
76
+ }
77
+ template <class Rec, class H>
78
+ inline bool _pf_drawing_na(const DrawingArena<Rec>& arena, H h) {
79
+ return !arena.alive(h.id);
80
+ }
81
+ template <class Rec>
82
+ inline void _pf_collect_drawings(DrawingArena<Rec>& arena, int keep,
83
+ const int32_t* pins, std::size_t pin_count) {
84
+ const std::size_t excess = arena.order().size() - static_cast<std::size_t>(keep);
85
+ std::vector<int32_t> doomed;
86
+ for (int32_t id : arena.order()) {
87
+ if (doomed.size() == excess) break;
88
+ if (std::find(pins, pins + pin_count, id) == pins + pin_count) doomed.push_back(id);
89
+ }
90
+ for (int32_t id : doomed) arena.erase(id);
91
+ }
92
+ """
93
+
94
+ # Drawing kinds TradingView collects, with their member collector.
95
+ DRAWING_COLLECTED = {
96
+ "line": "_pf_collect_lines_",
97
+ "box": "_pf_collect_boxes_",
98
+ "label": "_pf_collect_labels_",
99
+ }
100
+ # TradingView keeps up to max_<kind>_count + 5 live drawings of a kind.
101
+ DRAWING_COLLECTION_SLACK = 6
102
+
33
103
  # ---------------------------------------------------------------------------
34
104
  # Canonical Pine v6 constructor param-name lists (positional order).
35
105
  # Only the GEOMETRY names are consumed downstream; every other (visual) name is
@@ -197,7 +267,16 @@ class DrawingVisitor:
197
267
  return True
198
268
  return False
199
269
 
270
+ @staticmethod
271
+ def _collected(dtype: str, handle: str) -> str:
272
+ """A new drawing of a collected kind passes through its collector."""
273
+ collector = DRAWING_COLLECTED.get(dtype)
274
+ return f"{collector}({handle})" if collector else handle
275
+
200
276
  def _emit_drawing_ctor(self, dtype: str, node: FuncCall) -> str:
277
+ return self._collected(dtype, self._emit_drawing_alloc(dtype, node))
278
+
279
+ def _emit_drawing_alloc(self, dtype: str, node: FuncCall) -> str:
201
280
  arena = DRAWING_ARENA[dtype]
202
281
  if dtype == "linefill":
203
282
  vals = self._merge_drawing_args(node, ["line1", "line2", "color"])
@@ -219,8 +298,10 @@ class DrawingVisitor:
219
298
  y1 = self._visit_expr(vals.get("y1"))
220
299
  x2 = self._visit_expr(vals.get("x2"))
221
300
  y2 = self._visit_expr(vals.get("y2"))
222
- return (f"pf_line_new({arena}, (int64_t)({x1}), (double)({y1}), "
223
- f"(int64_t)({x2}), (double)({y2}), {xloc}, {el}, {er})")
301
+ x1 = self._coerce_int_slot_with_cast(x1, vals.get("x1"), "int64_t")
302
+ x2 = self._coerce_int_slot_with_cast(x2, vals.get("x2"), "int64_t")
303
+ return (f"pf_line_new({arena}, {x1}, (double)({y1}), "
304
+ f"{x2}, (double)({y2}), {xloc}, {el}, {er})")
224
305
 
225
306
  if dtype == "box":
226
307
  vals = self._merge_drawing_args(node, _BOX_CTOR_PTS if use_pts else _BOX_CTOR)
@@ -233,8 +314,10 @@ class DrawingVisitor:
233
314
  top = self._visit_expr(vals.get("top"))
234
315
  right = self._visit_expr(vals.get("right"))
235
316
  bottom = self._visit_expr(vals.get("bottom"))
236
- return (f"pf_box_new({arena}, (int64_t)({left}), (double)({top}), "
237
- f"(int64_t)({right}), (double)({bottom}), {xloc})")
317
+ left = self._coerce_int_slot_with_cast(left, vals.get("left"), "int64_t")
318
+ right = self._coerce_int_slot_with_cast(right, vals.get("right"), "int64_t")
319
+ return (f"pf_box_new({arena}, {left}, (double)({top}), "
320
+ f"{right}, (double)({bottom}), {xloc})")
238
321
 
239
322
  if dtype == "label":
240
323
  vals = self._merge_drawing_args(node, _LABEL_CTOR_PTS if use_pts else _LABEL_CTOR)
@@ -247,7 +330,8 @@ class DrawingVisitor:
247
330
  x = self._visit_expr(vals.get("x"))
248
331
  y = self._visit_expr(vals.get("y"))
249
332
  xloc = self._lower_xloc(vals.get("xloc"))
250
- return (f"pf_label_new({arena}, (int64_t)({x}), (double)({y}), "
333
+ x = self._coerce_int_slot_with_cast(x, vals.get("x"), "int64_t")
334
+ return (f"pf_label_new({arena}, {x}, (double)({y}), "
251
335
  f"{text}, {xloc}, {yloc})")
252
336
 
253
337
  return "0" # unreachable
@@ -269,20 +353,24 @@ class DrawingVisitor:
269
353
  vals = self._merge_drawing_args(node, ["index", "price"])
270
354
  idx = self._visit_expr(vals.get("index"))
271
355
  price = self._visit_expr(vals.get("price"))
272
- return (f"ChartPoint{{ .index=(int64_t)({idx}), .time=na<int64_t>(), "
356
+ idx = self._coerce_int_slot_with_cast(idx, vals.get("index"), "int64_t")
357
+ return (f"ChartPoint{{ .index={idx}, .time=na<int64_t>(), "
273
358
  f".price=({price}) }}")
274
359
  if func_name == "from_time":
275
360
  vals = self._merge_drawing_args(node, ["time", "price"])
276
361
  tm = self._visit_expr(vals.get("time"))
277
362
  price = self._visit_expr(vals.get("price"))
278
- return (f"ChartPoint{{ .index=na<int64_t>(), .time=(int64_t)({tm}), "
363
+ tm = self._coerce_int_slot_with_cast(tm, vals.get("time"), "int64_t")
364
+ return (f"ChartPoint{{ .index=na<int64_t>(), .time={tm}, "
279
365
  f".price=({price}) }}")
280
366
  if func_name == "new":
281
367
  vals = self._merge_drawing_args(node, ["time", "index", "price"])
282
368
  tm = self._visit_expr(vals.get("time"))
283
369
  idx = self._visit_expr(vals.get("index"))
284
370
  price = self._visit_expr(vals.get("price"))
285
- return (f"ChartPoint{{ .index=(int64_t)({idx}), .time=(int64_t)({tm}), "
371
+ idx = self._coerce_int_slot_with_cast(idx, vals.get("index"), "int64_t")
372
+ tm = self._coerce_int_slot_with_cast(tm, vals.get("time"), "int64_t")
373
+ return (f"ChartPoint{{ .index={idx}, .time={tm}, "
286
374
  f".price=({price}) }}")
287
375
  return "ChartPoint{}"
288
376
 
@@ -319,106 +407,88 @@ class DrawingVisitor:
319
407
  return self._emit_linefill_method(method, arena, recv, av)
320
408
  return "0"
321
409
 
410
+ # Getters and setters reach the arena through ``_pf_drawing_get`` /
411
+ # ``_pf_drawing_set`` (DRAWING_LIFETIME_CPP): a deleted, collected or na
412
+ # handle reads na and ignores the write. Every argument is still evaluated.
413
+ @staticmethod
414
+ def _live_get(fn: str, a: str, r: str, *args: str) -> str:
415
+ extra = "".join(", " + x for x in args)
416
+ return f"_pf_drawing_get({fn}, {a}, {r}{extra})"
417
+
418
+ @staticmethod
419
+ def _live_set(fn: str, a: str, r: str, *args: str) -> str:
420
+ extra = "".join(", " + x for x in args)
421
+ return f"_pf_drawing_set({fn}, {a}, {r}{extra})"
422
+
322
423
  def _emit_line_method(self, m, a, r, av, raw) -> str:
323
- if m == "get_x1":
324
- return f"pf_line_get_x1({a}, {r})"
325
- if m == "get_x2":
326
- return f"pf_line_get_x2({a}, {r})"
327
- if m == "get_y1":
328
- return f"pf_line_get_y1({a}, {r})"
329
- if m == "get_y2":
330
- return f"pf_line_get_y2({a}, {r})"
424
+ get, put = self._live_get, self._live_set
425
+ if m in ("get_x1", "get_x2", "get_y1", "get_y2"):
426
+ return get(f"pf_line_{m}", a, r)
331
427
  if m == "get_price":
332
- return f"pf_line_get_price({a}, {r}, (int64_t)({av[0]}))"
333
- if m == "set_x1":
334
- return f"pf_line_set_x1({a}, {r}, (int64_t)({av[0]}))"
335
- if m == "set_x2":
336
- return f"pf_line_set_x2({a}, {r}, (int64_t)({av[0]}))"
337
- if m == "set_y1":
338
- return f"pf_line_set_y1({a}, {r}, (double)({av[0]}))"
339
- if m == "set_y2":
340
- return f"pf_line_set_y2({a}, {r}, (double)({av[0]}))"
341
- if m == "set_xy1":
342
- return f"pf_line_set_xy1({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))"
343
- if m == "set_xy2":
344
- return f"pf_line_set_xy2({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))"
345
- if m == "set_first_point":
346
- return f"pf_line_set_first_point({a}, {r}, {av[0]})"
347
- if m == "set_second_point":
348
- return f"pf_line_set_second_point({a}, {r}, {av[0]})"
428
+ return get("pf_line_get_price", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'))
429
+ if m in ("set_x1", "set_x2"):
430
+ return put(f"pf_line_{m}", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'))
431
+ if m in ("set_y1", "set_y2"):
432
+ return put(f"pf_line_{m}", a, r, f"(double)({av[0]})")
433
+ if m in ("set_xy1", "set_xy2"):
434
+ return put(f"pf_line_{m}", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'), f"(double)({av[1]})")
435
+ if m in ("set_first_point", "set_second_point"):
436
+ return put(f"pf_line_{m}", a, r, av[0])
349
437
  if m == "set_xloc":
350
- return f"pf_line_set_xloc({a}, {r}, (int64_t)({av[0]}), (int64_t)({av[1]}), {self._lower_xloc(raw[2])})"
438
+ return put("pf_line_set_xloc", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'), self._coerce_int_slot_with_cast(av[1], raw[1], 'int64_t'), self._lower_xloc(raw[2]))
351
439
  if m == "copy":
352
- return f"pf_line_copy({a}, {r})"
440
+ return self._collected("line", f"pf_line_copy({a}, {r})")
353
441
  if m == "delete":
354
442
  return f"pf_line_delete({a}, {r})"
355
443
  return "0"
356
444
 
357
445
  def _emit_box_method(self, m, a, r, av, raw) -> str:
358
- if m == "get_left":
359
- return f"pf_box_get_left({a}, {r})"
360
- if m == "get_right":
361
- return f"pf_box_get_right({a}, {r})"
362
- if m == "get_top":
363
- return f"pf_box_get_top({a}, {r})"
364
- if m == "get_bottom":
365
- return f"pf_box_get_bottom({a}, {r})"
366
- if m == "set_left":
367
- return f"pf_box_set_left({a}, {r}, (int64_t)({av[0]}))"
368
- if m == "set_right":
369
- return f"pf_box_set_right({a}, {r}, (int64_t)({av[0]}))"
370
- if m == "set_top":
371
- return f"pf_box_set_top({a}, {r}, (double)({av[0]}))"
372
- if m == "set_bottom":
373
- return f"pf_box_set_bottom({a}, {r}, (double)({av[0]}))"
374
- if m == "set_lefttop":
375
- return f"pf_box_set_lefttop({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))"
376
- if m == "set_rightbottom":
377
- return f"pf_box_set_rightbottom({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))"
378
- if m == "set_top_left_point":
379
- return f"pf_box_set_top_left_point({a}, {r}, {av[0]})"
380
- if m == "set_bottom_right_point":
381
- return f"pf_box_set_bottom_right_point({a}, {r}, {av[0]})"
446
+ get, put = self._live_get, self._live_set
447
+ if m in ("get_left", "get_right", "get_top", "get_bottom"):
448
+ return get(f"pf_box_{m}", a, r)
449
+ if m in ("set_left", "set_right"):
450
+ return put(f"pf_box_{m}", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'))
451
+ if m in ("set_top", "set_bottom"):
452
+ return put(f"pf_box_{m}", a, r, f"(double)({av[0]})")
453
+ if m in ("set_lefttop", "set_rightbottom"):
454
+ return put(f"pf_box_{m}", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'), f"(double)({av[1]})")
455
+ if m in ("set_top_left_point", "set_bottom_right_point"):
456
+ return put(f"pf_box_{m}", a, r, av[0])
382
457
  if m == "set_xloc":
383
- return f"pf_box_set_xloc({a}, {r}, (int64_t)({av[0]}), (int64_t)({av[1]}), {self._lower_xloc(raw[2])})"
458
+ return put("pf_box_set_xloc", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'), self._coerce_int_slot_with_cast(av[1], raw[1], 'int64_t'), self._lower_xloc(raw[2]))
384
459
  if m == "copy":
385
- return f"pf_box_copy({a}, {r})"
460
+ return self._collected("box", f"pf_box_copy({a}, {r})")
386
461
  if m == "delete":
387
462
  return f"pf_box_delete({a}, {r})"
388
463
  return "0"
389
464
 
390
465
  def _emit_label_method(self, m, a, r, av, raw) -> str:
391
- if m == "get_x":
392
- return f"pf_label_get_x({a}, {r})"
393
- if m == "get_y":
394
- return f"pf_label_get_y({a}, {r})"
395
- if m == "get_text":
396
- return f"pf_label_get_text({a}, {r})"
466
+ get, put = self._live_get, self._live_set
467
+ if m in ("get_x", "get_y", "get_text"):
468
+ return get(f"pf_label_{m}", a, r)
397
469
  if m == "set_x":
398
- return f"pf_label_set_x({a}, {r}, (int64_t)({av[0]}))"
470
+ return put("pf_label_set_x", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'))
399
471
  if m == "set_y":
400
- return f"pf_label_set_y({a}, {r}, (double)({av[0]}))"
472
+ return put("pf_label_set_y", a, r, f"(double)({av[0]})")
401
473
  if m == "set_xy":
402
- return f"pf_label_set_xy({a}, {r}, (int64_t)({av[0]}), (double)({av[1]}))"
474
+ return put("pf_label_set_xy", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'), f"(double)({av[1]})")
403
475
  if m == "set_point":
404
- return f"pf_label_set_point({a}, {r}, {av[0]})"
476
+ return put("pf_label_set_point", a, r, av[0])
405
477
  if m == "set_xloc":
406
- return f"pf_label_set_xloc({a}, {r}, (int64_t)({av[0]}), {self._lower_xloc(raw[1])})"
478
+ return put("pf_label_set_xloc", a, r, self._coerce_int_slot_with_cast(av[0], raw[0], 'int64_t'), self._lower_xloc(raw[1]))
407
479
  if m == "set_yloc":
408
- return f"pf_label_set_yloc({a}, {r}, {self._lower_yloc(raw[0])})"
480
+ return put("pf_label_set_yloc", a, r, self._lower_yloc(raw[0]))
409
481
  if m == "set_text":
410
- return f"pf_label_set_text({a}, {r}, {av[0]})"
482
+ return put("pf_label_set_text", a, r, av[0])
411
483
  if m == "copy":
412
- return f"pf_label_copy({a}, {r})"
484
+ return self._collected("label", f"pf_label_copy({a}, {r})")
413
485
  if m == "delete":
414
486
  return f"pf_label_delete({a}, {r})"
415
487
  return "0"
416
488
 
417
489
  def _emit_linefill_method(self, m, a, r, av) -> str:
418
- if m == "get_line1":
419
- return f"pf_linefill_get_line1({a}, {r})"
420
- if m == "get_line2":
421
- return f"pf_linefill_get_line2({a}, {r})"
490
+ if m in ("get_line1", "get_line2"):
491
+ return self._live_get(f"pf_linefill_{m}", a, r)
422
492
  if m == "delete":
423
493
  return f"pf_linefill_delete({a}, {r})"
424
494
  return "0"
@@ -516,6 +586,16 @@ class DrawingVisitor:
516
586
 
517
587
  if self._drawing_call_is_void(node):
518
588
  return True
589
+ receiver = node.callee.object
590
+ if (isinstance(receiver, Identifier)
591
+ and (receiver.name, method) in _VOID_BUILTIN_CALLS):
592
+ return True
593
+ if (isinstance(receiver, MemberAccess)
594
+ and isinstance(receiver.object, Identifier)
595
+ and receiver.object.name == "strategy"
596
+ and receiver.member == "risk"):
597
+ # strategy.risk.* setters are statements (see _visit_stmt).
598
+ return True
519
599
  _fn, ns = self._resolve_callee(node.callee)
520
600
 
521
601
  if method in _MATRIX_VOID_METHODS:
@@ -571,6 +651,68 @@ class DrawingVisitor:
571
651
  recv_spec = param_spec or self._type_spec_from_expr(node.callee.object)
572
652
  return recv_spec is not None and recv_spec.kind == "map"
573
653
 
654
+ # ------------------------------------------------------------------
655
+ # Drawing lifetime: na(), the collection and its pins
656
+ # ------------------------------------------------------------------
657
+ def _drawing_na_expr(self, arg) -> str | None:
658
+ """``na(<line|box|label|linefill>)`` asks the arena, which knows a
659
+ deleted or collected drawing; ``is_na`` sees only a na handle."""
660
+ if not getattr(self, "_uses_drawing", False):
661
+ return None
662
+ spec = self._type_spec_from_expr(arg)
663
+ if spec is None or spec.kind != "udt" or spec.name not in DRAWING_ARENA:
664
+ return None
665
+ return f"_pf_drawing_na({DRAWING_ARENA[spec.name]}, {self._visit_expr(arg)})"
666
+
667
+ _DRAWING_PIN_DECL = re.compile(
668
+ r"^ (?:Series<(Line|Box|Label)>|(Line|Box|Label)) (\w+)\b")
669
+
670
+ @classmethod
671
+ def _drawing_pin_members(cls, declarations: list[str]) -> dict[str, list[str]]:
672
+ """The current value of every drawing-typed pinning variable, per kind.
673
+
674
+ ``declarations`` are the generated member lines of the ``var`` /
675
+ ``varip`` variables (global, block and callable, with their
676
+ per-call-site and fresh clones) and of the non-var variables whose
677
+ history is read. TradingView keeps a drawing one of them holds. A
678
+ drawing held only by an array, an object field, a local or a plain
679
+ non-var variable is collectable, as is one a ``var`` held before it
680
+ was reassigned (tests/fixtures/drawing_lifetime ``w9dg-pin-*``).
681
+ """
682
+ kinds = {"Line": "line", "Box": "box", "Label": "label"}
683
+ pins: dict[str, list[str]] = {kind: [] for kind in kinds.values()}
684
+ for line in declarations:
685
+ match = cls._DRAWING_PIN_DECL.match(line)
686
+ if match is None:
687
+ continue
688
+ series, plain, name = match.groups()
689
+ current = f"this->{name}[0]" if series else f"this->{name}"
690
+ pins[kinds[series or plain]].append(f"{current}.id")
691
+ return pins
692
+
693
+ def _emit_drawing_collectors(self, lines: list[str]) -> None:
694
+ """One collector per collected kind: a new drawing passes through it,
695
+ and the one that makes the kind's live count reach its
696
+ max_<kind>_count + 6 deletes the oldest drawings no pinning variable
697
+ (``_drawing_pin_members``) holds."""
698
+ pins = getattr(self, "_drawing_pins", None) or {}
699
+ caps = self._drawing_caps or {}
700
+ for dtype, collector in DRAWING_COLLECTED.items():
701
+ handle = DRAWING_TYPE_TO_CPP[dtype]
702
+ arena = DRAWING_ARENA[dtype]
703
+ keep = caps.get(dtype, 50)
704
+ held = ", ".join(["_pf_new.id", *pins.get(dtype, ())])
705
+ lines.extend([
706
+ f" {handle} {collector}({handle} _pf_new) {{",
707
+ f" if ((int)this->{arena}.order().size() >= {keep + DRAWING_COLLECTION_SLACK}) {{",
708
+ f" const int32_t _pf_held[] = {{{held}}};",
709
+ f" _pf_collect_drawings(this->{arena}, {keep}, _pf_held,",
710
+ " sizeof(_pf_held) / sizeof(_pf_held[0]));",
711
+ " }",
712
+ " return _pf_new;",
713
+ " }",
714
+ ])
715
+
574
716
  # ------------------------------------------------------------------
575
717
  # _uses_drawing detection + arena caps
576
718
  # ------------------------------------------------------------------