@pineforge/codegen-pyodide 1.0.0 → 1.1.0

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 (31) hide show
  1. package/glue.py +2 -1
  2. package/package.json +1 -1
  3. package/pineforge_codegen/__init__.py +24 -9
  4. package/pineforge_codegen/analyzer/base.py +190 -1
  5. package/pineforge_codegen/analyzer/call_handlers.py +2 -0
  6. package/pineforge_codegen/analyzer/contracts.py +6 -0
  7. package/pineforge_codegen/analyzer/types.py +16 -5
  8. package/pineforge_codegen/ast_nodes.py +7 -0
  9. package/pineforge_codegen/codegen/base.py +136 -14
  10. package/pineforge_codegen/codegen/checked_settings.py +272 -0
  11. package/pineforge_codegen/codegen/collection_history.py +200 -0
  12. package/pineforge_codegen/codegen/emit_top.py +76 -14
  13. package/pineforge_codegen/codegen/helpers.py +12 -2
  14. package/pineforge_codegen/codegen/host_members.py +8 -0
  15. package/pineforge_codegen/codegen/input.py +36 -3
  16. package/pineforge_codegen/codegen/security.py +56 -1
  17. package/pineforge_codegen/codegen/ta.py +65 -1
  18. package/pineforge_codegen/codegen/types.py +136 -8
  19. package/pineforge_codegen/codegen/visit_call.py +92 -3
  20. package/pineforge_codegen/codegen/visit_expr.py +202 -3
  21. package/pineforge_codegen/codegen/visit_stmt.py +19 -4
  22. package/pineforge_codegen/collection_history.py +1595 -0
  23. package/pineforge_codegen/external_requests.py +3 -0
  24. package/pineforge_codegen/parser.py +19 -4
  25. package/pineforge_codegen/request_discovery.py +506 -0
  26. package/pineforge_codegen/support_checker.py +37 -0
  27. package/pineforge_codegen-1.1.0.tar.gz +0 -0
  28. package/release.json +2 -2
  29. package/tables.json +1 -1
  30. package/transpile.worker.mjs +2 -1
  31. package/pineforge_codegen-1.0.0.tar.gz +0 -0
package/glue.py CHANGED
@@ -40,4 +40,5 @@ def transpile_json(source: str) -> str:
40
40
  # A script that transpiled carries its warnings in the same entry format.
41
41
  return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"],
42
42
  "strategyParams": full["strategyParams"],
43
- "diagnostics": _diagnostic_entries(full["diagnostics"])})
43
+ "diagnostics": _diagnostic_entries(full["diagnostics"]),
44
+ "requests": full["requests"]})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineforge/codegen-pyodide",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Gate-validated Pyodide payload for the PineScript v6 -> C++ transpiler: archive (run in Pyodide), unpacked source, introspected tables, and release metadata.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -13,6 +13,7 @@ from .finite_ta_length import expand_finite_choice_extrema_lengths
13
13
  from .library_inline import inline_libraries
14
14
  from .limits import TimeBudget, check_ast_depth, check_source_size, ensure_recursion_headroom
15
15
  from .pragmas import extract_pf_trace_pragmas
16
+ from .request_discovery import discover_requests, request_sites
16
17
  from .security_contexts import specialize_security_contexts
17
18
  from .block_locals import rename_block_locals
18
19
  from .support_checker import check_support as _support_diagnostics
@@ -52,7 +53,9 @@ def _generate(pine_source: str, check_support: bool, filename: str,
52
53
  the member of its name cannot hold gets a name of its own, and the
53
54
  pipeline runs again (``block_locals``).
54
55
 
55
- Returns ``(codegen, ctx, cpp, support_diagnostics)``."""
56
+ Returns ``(codegen, ctx, cpp, support_diagnostics, sites)``: ``sites``
57
+ are the requests of another symbol's data the support checker lowered
58
+ (``request_discovery.request_sites``)."""
56
59
  budget = None
57
60
  clones: frozenset[str] = frozenset()
58
61
  renamed: frozenset = frozenset()
@@ -67,6 +70,7 @@ def _generate(pine_source: str, check_support: bool, filename: str,
67
70
  if any(d.level == Level.ERROR for d in support_diagnostics):
68
71
  raise CompileError(support_diagnostics)
69
72
  budget.check(phase=Phase.ANALYZER)
73
+ sites = request_sites(ast)
70
74
  ast = lower_no_data_requests(ast)
71
75
  ast = specialize_security_contexts(ast, filename=filename)
72
76
  ast = bind_builtin_keywords(expand_finite_choice_extrema_lengths(ast))
@@ -86,18 +90,18 @@ def _generate(pine_source: str, check_support: bool, filename: str,
86
90
  apart = gen.block_locals_needing_names - renamed
87
91
  if apart:
88
92
  renamed |= apart
89
- del ast, ctx, gen, cpp
93
+ del ast, ctx, gen, cpp, sites
90
94
  continue
91
95
  needed = gen.session_functions_needing_clones
92
96
  if not needed:
93
- return gen, ctx, cpp, support_diagnostics
97
+ return gen, ctx, cpp, support_diagnostics, sites
94
98
  # Only uncloned functions ask, so the set grows each time and the
95
99
  # loop ends by the time every function that reads a flag at an
96
100
  # offset is cloned.
97
101
  if needed <= clones:
98
102
  raise AssertionError("a cloned function asked for session clones again")
99
103
  clones |= needed
100
- del ast, ctx, gen, cpp
104
+ del ast, ctx, gen, cpp, sites
101
105
 
102
106
 
103
107
  def transpile(pine_source: str, *, check_support: bool = True, filename: str = "<input>",
@@ -140,7 +144,8 @@ def transpile(pine_source: str, *, check_support: bool = True, filename: str = "
140
144
  Returns:
141
145
  Generated C++ source string.
142
146
  """
143
- _gen, _ctx, cpp, _support = _generate(pine_source, check_support, filename, libraries)
147
+ _gen, _ctx, cpp, _support, _sites = _generate(pine_source, check_support, filename,
148
+ libraries)
144
149
  return cpp
145
150
 
146
151
 
@@ -161,26 +166,36 @@ def transpile_full(pine_source: str, *, check_support: bool = True,
161
166
  source order). Each has ``title`` / ``type`` /
162
167
  ``default`` and optionally ``min`` / ``max`` / ``step`` / ``options``
163
168
  (omitted when the corresponding signature argument is absent or
164
- references a non-const value). See
165
- :meth:`CodeGen.extract_input_manifest`.
169
+ references a non-const value); an ``input.symbol`` entry also has
170
+ ``kind: "symbol"``. See :meth:`CodeGen.extract_input_manifest`.
166
171
  - ``strategyParams``: the literal ``strategy(...)`` kwargs the analyzer
167
172
  surfaced (e.g. ``initial_capital``, ``pyramiding``).
168
173
  - ``diagnostics``: the warnings (:class:`~pineforge_codegen.errors.Diagnostic`,
169
174
  ``Level.WARNING``) the support checker and the analyzer raised for a
170
175
  script that transpiled -- e.g. an approximated ``ta.vwap`` anchor. An
171
176
  error still raises ``CompileError``, which carries the warnings too.
177
+ - ``requests``: every request site that reads another symbol's feed,
178
+ by line: its symbol and timeframe as registration computes them
179
+ before the first bar (``literal`` / ``input`` / ``computed`` /
180
+ ``unresolvable``; ``literal`` / ``chart`` / ``input`` / ``computed``),
181
+ ``lookahead``, ``gaps`` and ``ignore_invalid_symbol``. A site lowered
182
+ to ``na`` (its value reaches display sinks only) or in a helper
183
+ nothing reaches is not listed. See
184
+ :mod:`pineforge_codegen.request_discovery`.
172
185
 
173
186
  Args mirror :func:`transpile`.
174
187
 
175
188
  Returns:
176
189
  ``{"cpp": str, "inputs": list[dict], "strategyParams": dict,
177
- "diagnostics": list[Diagnostic]}``.
190
+ "diagnostics": list[Diagnostic], "requests": list[dict]}``.
178
191
  """
179
- gen, ctx, cpp, support_diagnostics = _generate(pine_source, check_support, filename, libraries)
192
+ gen, ctx, cpp, support_diagnostics, sites = _generate(
193
+ pine_source, check_support, filename, libraries)
180
194
  return {
181
195
  "cpp": cpp,
182
196
  "inputs": gen.extract_input_manifest(),
183
197
  "strategyParams": dict(ctx.strategy_params),
184
198
  "diagnostics": [d for d in (*support_diagnostics, *ctx.diagnostics)
185
199
  if d.level == Level.WARNING],
200
+ "requests": discover_requests(gen, ctx, sites),
186
201
  }
@@ -32,6 +32,10 @@ from ..errors import SourceLocation, Diagnostic, CompileError, Level, Phase
32
32
  from ..limits import TimeBudget, iter_ast_nodes
33
33
  from ..security_contexts import PASS_WARNINGS_ANNOTATION, context_key, reads_bar_series
34
34
  from ..session_reads import emitted_session_reads
35
+ from ..collection_history import (
36
+ COLLECTION_HISTORY_ANNOTATION, LEGACY, REFUSED, SUPPORTED,
37
+ CollectionHistoryChecker, HistoryRead,
38
+ )
35
39
  from ..method_binding import (
36
40
  BoundMethodArgs,
37
41
  MethodBindError,
@@ -431,6 +435,13 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
431
435
  # pass can tell borrowed clones apart from a dead function's own
432
436
  # sites (see contracts.TACallSite.owner_func).
433
437
  self._enclosing_func_names: list[str] = []
438
+ # Array and matrix history reads (``a[1]``), each decided where the
439
+ # visit meets it (pineforge_codegen/collection_history.py), and the
440
+ # declarations writing each class member, by name (a sibling block's
441
+ # declaration closes another one's history).
442
+ self._collection_history_reads: list[HistoryRead] = []
443
+ self._collection_history_checker: CollectionHistoryChecker | None = None
444
+ self._member_writer_decls: dict[str, set[int]] = {}
434
445
  # Exact TA targets borrowed through nested callable edges while visiting
435
446
  # the current callable body. Constructor templates are tracked
436
447
  # separately in ``_func_ta_ctor_args`` because state ownership also
@@ -552,6 +563,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
552
563
  self._ensure_pine_v6()
553
564
  self._check_direct_terminal_array_element_callee_shadows()
554
565
  self._visit(self._ast)
566
+ collection_history = self._check_collection_history()
555
567
  self._check_direct_terminal_array_temporary_cycles()
556
568
  self._register_resolved_direct_terminal_array_forward_calls()
557
569
  self._refresh_direct_terminal_array_temporary_returns()
@@ -652,6 +664,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
652
664
  for owner, names in self._func_var_storage_names.items()
653
665
  },
654
666
  func_series_vars=self._func_series_vars,
667
+ collection_history=collection_history,
655
668
  func_global_history_reads={
656
669
  owner: list(names)
657
670
  for owner, names in self._func_global_history_reads.items()
@@ -3784,6 +3797,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3784
3797
  symbol=sec.symbol,
3785
3798
  foreign=sec.foreign,
3786
3799
  ignore_invalid=sec.ignore_invalid,
3800
+ loc=sec.loc,
3787
3801
  )
3788
3802
  new_calls.append(clone)
3789
3803
  next_sec_id += 1
@@ -4345,6 +4359,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4345
4359
  self._symbols.define(sym)
4346
4360
  setattr(sym, "_pf_decl_node_id", id(node))
4347
4361
  setattr(sym, "_pf_decl_binding_name", node.name)
4362
+ self._note_member_writer(sym, node.name, node)
4348
4363
  if (type_spec is None
4349
4364
  and self._collection_scope_stack
4350
4365
  and self._block_node_stack
@@ -4647,6 +4662,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
4647
4662
  self._symbols.define(sym)
4648
4663
  setattr(sym, "_pf_decl_node_id", id(node))
4649
4664
  setattr(sym, "_pf_decl_binding_name", name)
4665
+ self._note_member_writer(sym, name, node)
4650
4666
 
4651
4667
  if (self._collection_scope_stack
4652
4668
  and self._block_node_stack
@@ -6174,6 +6190,19 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6174
6190
  if self._type_spec_contains_map(object_spec):
6175
6191
  assert object_spec is not None
6176
6192
  self._reject_unsupported_map_history(object_spec, node)
6193
+ if (object_spec is not None
6194
+ and object_spec.kind in ("array", "matrix")):
6195
+ # A parameter given an array or a matrix: its history is a
6196
+ # parameter's, which PineForge does not keep; the earlier
6197
+ # lowering stays where it compiled (collection_history.py).
6198
+ # A selection of one is an expression's history.
6199
+ checker = self._history_checker()
6200
+ if isinstance(node.object, Identifier):
6201
+ decision = checker.decide_parameter_history(node, object_spec)
6202
+ if decision.outcome == REFUSED:
6203
+ self._collection_history_raise(decision, node)
6204
+ else:
6205
+ self._refuse_collection_expression_history(node, object_spec)
6177
6206
 
6178
6207
  # Propagate concrete caller specs through wrapper calls. Each edge is
6179
6208
  # identity-keyed from the definition pass, so a local that shadows a
@@ -6428,8 +6457,10 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6428
6457
  def _visit_Subscript(self, node: Subscript) -> PineType:
6429
6458
  obj_type = self._visit(node.object)
6430
6459
  self._visit(node.index)
6460
+ self._refuse_collection_field_history(node)
6431
6461
 
6432
6462
  object_spec = self._history_receiver_type_spec(node.object)
6463
+ self._refuse_collection_expression_history(node, object_spec)
6433
6464
  if self._type_spec_contains_map(object_spec):
6434
6465
  assert object_spec is not None
6435
6466
  self._reject_unsupported_map_history(object_spec, node)
@@ -6461,7 +6492,17 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6461
6492
  "Series buffer for that binding.",
6462
6493
  node.loc,
6463
6494
  )
6464
- if getattr(sym, "type_spec", None) is None or sym.type_spec.kind not in ("array", "map"):
6495
+ # An array's or a matrix's history: copies kept beside
6496
+ # the variable, not a Series of it (collection_history.py);
6497
+ # a legacy matrix parameter's read keeps the registration
6498
+ # below, as it had.
6499
+ keeps_series = not (
6500
+ getattr(sym, "type_spec", None) is not None
6501
+ and sym.type_spec.kind in ("array", "matrix")
6502
+ and not self._note_collection_history_read(node, name, sym)
6503
+ )
6504
+ if keeps_series and (getattr(sym, "type_spec", None) is None
6505
+ or sym.type_spec.kind not in ("array", "map")):
6465
6506
  exact_member = getattr(sym, "_pf_var_member_name", None)
6466
6507
  if exact_member is not None:
6467
6508
  self._series_var_members.add(exact_member)
@@ -6510,6 +6551,150 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6510
6551
 
6511
6552
  return obj_type
6512
6553
 
6554
+ def _refuse_collection_field_history(self, node: Subscript) -> None:
6555
+ """The history of an object's array, matrix or map field: TradingView
6556
+ refuses ``h.xs[1]`` and ``(h.xs)[1]`` alike ("Cannot use the
6557
+ history-referencing operator on fields of user-defined types",
6558
+ CE10290: fixtures/array_history_tv ahist_field_noparen,
6559
+ ahist_field_paren, ahist_mfield) and takes ``(h[1]).xs``. The C++
6560
+ indexed the field's current collection."""
6561
+ field = node.object
6562
+ if not isinstance(field, MemberAccess):
6563
+ return
6564
+ owner = self._history_receiver_type_spec(field.object)
6565
+ if (owner is None or owner.kind != "udt"
6566
+ or owner.name not in self._udt_fields):
6567
+ return
6568
+ spec = (self._udt_field_type_specs.get(owner.name) or {}).get(field.member)
6569
+ if spec is None or spec.kind not in ("array", "matrix", "map"):
6570
+ return
6571
+ receiver = (field.object.name if isinstance(field.object, Identifier)
6572
+ else "object")
6573
+ raise CompileError([Diagnostic(
6574
+ level=Level.ERROR,
6575
+ phase=Phase.ANALYZER,
6576
+ location=node.loc or SourceLocation(self._filename, 1, 1, 1),
6577
+ message=(f"{receiver}.{field.member}[...]: TradingView refuses the "
6578
+ "history-referencing operator on fields of user-defined "
6579
+ "types (CE10290)."),
6580
+ hint=(f"Read the field of the object's history: "
6581
+ f"({receiver}[1]).{field.member}."),
6582
+ )])
6583
+
6584
+ def _history_checker(self) -> CollectionHistoryChecker:
6585
+ """The script's array and matrix history checker, built when the
6586
+ visit first meets such a read."""
6587
+ if self._collection_history_checker is None:
6588
+ self._collection_history_checker = CollectionHistoryChecker(
6589
+ self._ast, self._collection_history_name_kind)
6590
+ return self._collection_history_checker
6591
+
6592
+ def _collection_history_name_kind(self, name: str) -> str | None:
6593
+ """``array`` / ``matrix`` / ``map`` / ``scalar`` for a script
6594
+ variable, else None (collection_history.CollectionHistoryChecker)."""
6595
+ sym = self._symbols.global_scope.symbols.get(name)
6596
+ if sym is None:
6597
+ return None
6598
+ spec = getattr(sym, "type_spec", None)
6599
+ if spec is not None and spec.kind in ("array", "matrix", "map"):
6600
+ return spec.kind
6601
+ if sym.pine_type in (PineType.INT, PineType.FLOAT, PineType.BOOL,
6602
+ PineType.STRING, PineType.COLOR):
6603
+ return "scalar"
6604
+ return None
6605
+
6606
+ def _note_member_writer(self, sym, name: str, node) -> None:
6607
+ """A declaration writing the class member of its name (the script's
6608
+ top level and its blocks): a sibling block's declaration of the name
6609
+ closes another one's array history before it writes
6610
+ (codegen/collection_history.py)."""
6611
+ if sym.scope == "global":
6612
+ self._member_writer_decls.setdefault(name, set()).add(id(node))
6613
+
6614
+ def _collection_history_raise(self, decision, node) -> None:
6615
+ raise CompileError([Diagnostic(
6616
+ level=Level.ERROR,
6617
+ phase=Phase.ANALYZER,
6618
+ location=(getattr(decision.node, "loc", None) or node.loc
6619
+ or SourceLocation(self._filename, 1, 1, 1)),
6620
+ message=decision.message,
6621
+ hint=decision.hint,
6622
+ )])
6623
+
6624
+ def _refuse_collection_expression_history(self, node: Subscript, spec) -> None:
6625
+ """The history of an expression whose value is an array or a matrix
6626
+ (a call's result, a selection): TradingView reads the copy the
6627
+ expression produced k executions back (fixtures/array_history_tv
6628
+ ahist_fn ``c1``), which PineForge does not keep. A use whose earlier
6629
+ lowering compiled keeps it; one only a collection fits never compiled
6630
+ and is refused (collection_history.py)."""
6631
+ if (isinstance(node.object, Identifier) or spec is None
6632
+ or spec.kind not in ("array", "matrix")):
6633
+ return
6634
+ checker = self._history_checker()
6635
+ decided = checker.decision_for(node) is not None
6636
+ decision = checker.decide_expression(node, spec)
6637
+ if decision.outcome == REFUSED:
6638
+ self._collection_history_raise(decision, node)
6639
+ if decision.outcome == LEGACY and decision.use == "element" and not decided:
6640
+ self._warn(
6641
+ "(...)[...] reads an element of the selected current array in "
6642
+ "PineForge: no history of a selection is kept, where TradingView "
6643
+ "reads the array it selected that many bars back.",
6644
+ node.loc,
6645
+ )
6646
+
6647
+ def _note_collection_history_read(self, node: Subscript, name: str, sym) -> bool:
6648
+ """Decide ``a[k]`` of an array or a matrix variable
6649
+ (collection_history.py): annotate a supported read, refuse a refused
6650
+ one, and keep a legacy one's earlier lowering. True where that
6651
+ lowering registered a series (a matrix parameter's), which the caller
6652
+ then does as it did."""
6653
+ decl_node_id = getattr(sym, "_pf_decl_node_id", None)
6654
+ read = HistoryRead(
6655
+ node=node,
6656
+ name=name,
6657
+ kind=sym.type_spec.kind,
6658
+ spec=sym.type_spec,
6659
+ in_callable=bool(self._enclosing_func_names),
6660
+ is_parameter=sym.scope != "global" and decl_node_id is None,
6661
+ is_global=sym.scope == "global",
6662
+ is_var=bool(sym.is_var),
6663
+ decl_node_id=decl_node_id,
6664
+ scope_name=sym.scope or "",
6665
+ )
6666
+ decision = self._history_checker().decide(read)
6667
+ if node.annotations and COLLECTION_HISTORY_ANNOTATION in node.annotations:
6668
+ node.annotations = dict(node.annotations)
6669
+ del node.annotations[COLLECTION_HISTORY_ANNOTATION]
6670
+ if decision.outcome == REFUSED:
6671
+ self._collection_history_raise(decision, node)
6672
+ if decision.outcome == SUPPORTED:
6673
+ node.annotations = dict(node.annotations or {})
6674
+ node.annotations[COLLECTION_HISTORY_ANNOTATION] = {
6675
+ "var": name, "kind": read.kind, "use": decision.use,
6676
+ "spec": read.spec, "member": None,
6677
+ }
6678
+ if not any(r.node is node for r in self._collection_history_reads[-1:]):
6679
+ self._collection_history_reads.append(read)
6680
+ return False
6681
+ return decision.outcome == LEGACY and decision.use == "series"
6682
+
6683
+ def _collection_history_is_supported(self, node: Subscript) -> bool:
6684
+ """Whether the checker supports the history read ``node`` (decided
6685
+ where the visit met it; a read not met yet keeps its old typing)."""
6686
+ checker = self._collection_history_checker
6687
+ decision = checker.decision_for(node) if checker is not None else None
6688
+ return decision is not None and decision.outcome == SUPPORTED
6689
+
6690
+ def _check_collection_history(self) -> dict:
6691
+ """The declarations whose history the supported reads need, keyed
6692
+ by their members (pineforge_codegen/collection_history.py)."""
6693
+ if not self._collection_history_reads:
6694
+ return {}
6695
+ return self._history_checker().history_variables(
6696
+ self._collection_history_reads, self._member_writer_decls)
6697
+
6513
6698
  def _note_function_global_history_read(
6514
6699
  self, node: Subscript, name: str, sym) -> None:
6515
6700
  """Record ``x[k]`` in a plain UDF body on a script variable.
@@ -6807,6 +6992,10 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
6807
6992
  if owner_spec is not None and owner_spec.kind == "udt" and owner_spec.name:
6808
6993
  field_spec = (self._udt_field_type_specs.get(owner_spec.name) or {}).get(node.member)
6809
6994
  if field_spec is not None:
6995
+ # The receiver is read too: ``(o[1]).inner.v`` reads the
6996
+ # history of ``o`` (fixtures/udt_history_tv udth_ref),
6997
+ # which only its visit registers.
6998
+ self._visit(node.object)
6810
6999
  return self._type_hint_to_pine(str(field_spec))
6811
7000
  self._visit(node.object)
6812
7001
  # strategy.closedtrades.* and strategy.opentrades.* return types
@@ -876,6 +876,7 @@ class CallHandlers:
876
876
  dead=bool(notes.get(DEAD_ANNOTATION)),
877
877
  foreign=notes.get(LOWERING_ANNOTATION) == FEED_LOWERING,
878
878
  ignore_invalid=node.kwargs.get("ignore_invalid_symbol"),
879
+ loc=node.loc,
879
880
  ))
880
881
 
881
882
  return PineType.STRING if string_result else PineType.FLOAT
@@ -998,6 +999,7 @@ class CallHandlers:
998
999
  is_lower_tf_array=True,
999
1000
  context_resolved=context is not None,
1000
1001
  dead=bool(notes.get(DEAD_ANNOTATION)),
1002
+ loc=node.loc,
1001
1003
  ))
1002
1004
 
1003
1005
  # ``request.security_lower_tf`` returns an array; the value-level
@@ -196,6 +196,9 @@ class SecurityCallInfo:
196
196
  # ``symbol`` string and ``ignore_invalid_symbol`` (the node, or None).
197
197
  foreign: bool = False
198
198
  ignore_invalid: Any = None
199
+ # The request call's source location (``request_discovery`` names the
200
+ # site by its line).
201
+ loc: Any = None
199
202
 
200
203
 
201
204
  @dataclass
@@ -337,6 +340,9 @@ class AnalyzerContext:
337
340
  # per read. TradingView keeps that history per call site, so codegen gives
338
341
  # each emitted body its own chart-clocked buffer of the variable.
339
342
  func_global_history_reads: dict = field(default_factory=dict)
343
+ # Array and matrix variables whose history the script reads, by name
344
+ # (pineforge_codegen/collection_history.py HistoryVariable).
345
+ collection_history: dict = field(default_factory=dict)
340
346
  func_global_history_nodes: dict = field(default_factory=dict)
341
347
  # FuncDef owner -> declaration-bound non-persistent history-local names.
342
348
  # Parameters are deliberately excluded so a same-named qualified
@@ -347,14 +347,25 @@ class TypeHelper:
347
347
  if isinstance(value, Subscript):
348
348
  # Pine's history operator preserves the value type: a
349
349
  # ``Series<line>`` read such as ``h[1]`` is a scalar ``line``
350
- # handle, not the legacy numeric fallback. Keep this refinement
351
- # drawing-only; collection subscripts have separate array/map
352
- # semantics and primitive history inference already flows through
353
- # PineType in ``_visit_Subscript``.
350
+ # handle and ``c[1]`` of a user-defined object the reference ``c``
351
+ # held (fixtures/udt_history_tv), not the legacy numeric fallback.
352
+ # Collection subscripts have separate array/map semantics and
353
+ # primitive history inference already flows through PineType in
354
+ # ``_visit_Subscript``.
354
355
  receiver_spec = self._type_spec_from_expr(value.object)
355
356
  if (receiver_spec is not None
356
357
  and receiver_spec.kind == "udt"
357
- and receiver_spec.name in _DRAWING_TYPE_NAMES):
358
+ and (receiver_spec.name in _DRAWING_TYPE_NAMES
359
+ or receiver_spec.name in self._udt_fields)):
360
+ return receiver_spec
361
+ if (receiver_spec is not None
362
+ and receiver_spec.kind in ("array", "matrix")
363
+ and isinstance(value.object, Identifier)
364
+ and self._collection_history_is_supported(value)):
365
+ # An array's or a matrix's history is a collection of its
366
+ # type (fixtures/array_history_tv; pineforge_codegen/
367
+ # collection_history.py); a legacy read keeps the element
368
+ # typing it had.
358
369
  return receiver_spec
359
370
  return None
360
371
  if isinstance(value, IfStmt):
@@ -6,6 +6,13 @@ from typing import Any
6
6
  from pineforge_codegen.errors import SourceLocation
7
7
 
8
8
 
9
+ # A MemberAccess (a field, or a method call's callee) written straight after a
10
+ # history reference, ``c[1].v`` / ``b[1].get_top()``: TradingView refuses it
11
+ # (CE10011 / CE10010) and takes ``(c[1]).v``, which parses to the same nodes.
12
+ # Set by the parser, refused by the support checker.
13
+ HISTORY_MEMBER_ANNOTATION = "pf_history_member"
14
+
15
+
9
16
  # ---------------------------------------------------------------------------
10
17
  # Base class
11
18
  # ---------------------------------------------------------------------------