@pineforge/codegen-pyodide 0.7.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 (37) hide show
  1. package/README.md +25 -0
  2. package/index.mjs +20 -0
  3. package/package.json +32 -0
  4. package/pineforge_codegen/__init__.py +53 -0
  5. package/pineforge_codegen/analyzer/__init__.py +60 -0
  6. package/pineforge_codegen/analyzer/base.py +1566 -0
  7. package/pineforge_codegen/analyzer/call_handlers.py +895 -0
  8. package/pineforge_codegen/analyzer/contracts.py +163 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +118 -0
  10. package/pineforge_codegen/analyzer/tables.py +204 -0
  11. package/pineforge_codegen/analyzer/types.py +261 -0
  12. package/pineforge_codegen/ast_nodes.py +293 -0
  13. package/pineforge_codegen/codegen/__init__.py +78 -0
  14. package/pineforge_codegen/codegen/base.py +1381 -0
  15. package/pineforge_codegen/codegen/emit_top.py +875 -0
  16. package/pineforge_codegen/codegen/helpers.py +163 -0
  17. package/pineforge_codegen/codegen/helpers_syminfo.py +132 -0
  18. package/pineforge_codegen/codegen/input.py +189 -0
  19. package/pineforge_codegen/codegen/security.py +1564 -0
  20. package/pineforge_codegen/codegen/ta.py +298 -0
  21. package/pineforge_codegen/codegen/tables.py +683 -0
  22. package/pineforge_codegen/codegen/types.py +592 -0
  23. package/pineforge_codegen/codegen/visit_call.py +1387 -0
  24. package/pineforge_codegen/codegen/visit_expr.py +729 -0
  25. package/pineforge_codegen/codegen/visit_stmt.py +766 -0
  26. package/pineforge_codegen/errors.py +98 -0
  27. package/pineforge_codegen/lexer.py +531 -0
  28. package/pineforge_codegen/parser.py +1198 -0
  29. package/pineforge_codegen/pragmas.py +117 -0
  30. package/pineforge_codegen/signatures.py +808 -0
  31. package/pineforge_codegen/support_checker.py +1223 -0
  32. package/pineforge_codegen/symbols.py +118 -0
  33. package/pineforge_codegen/tokens.py +406 -0
  34. package/pineforge_codegen/tv_input_choices.py +86 -0
  35. package/pineforge_codegen-0.7.0.tar.gz +0 -0
  36. package/release.json +7 -0
  37. package/tables.json +1653 -0
@@ -0,0 +1,1223 @@
1
+ """PineScript v6 support checker for PineForge.
2
+
3
+ Walks a parsed AST and rejects scripts that use language constructs, built-in
4
+ functions, variables, or `request.security` parameters that PineForge cannot
5
+ faithfully execute. Source-of-truth tables are imported from the transpiler's
6
+ own analyzer/codegen/signatures modules so the checker cannot drift.
7
+
8
+ Buckets:
9
+
10
+ * HARD_REJECT_FUNC / HARD_REJECT_NAMESPACE - calls that have no PineForge
11
+ semantics at all (e.g. ``request.financial``, ``ticker.*``).
12
+ * DIVERGENT_VARS - built-in variables whose PineForge value diverges from
13
+ TradingView (e.g. ``bar_index`` depends on data window, ``last_bar_index``
14
+ is wrongly aliased in codegen). Reported as WARNING — these often appear
15
+ in visual or logging code that does not affect trade outcomes.
16
+ * NOT_YET - calls the runtime could support but the transpiler does not yet
17
+ emit (e.g. ``max_bars_back``, bare ``barssince``).
18
+ * request.security - only ``symbol`` / ``timeframe`` / ``expression`` allowed,
19
+ symbol must be the current chart symbol.
20
+ * Declarations - only ``strategy(...)`` accepted; ``indicator(...)`` and
21
+ ``library(...)`` rejected.
22
+ * Unknown ``ta.X`` / ``math.X`` / ``str.X`` / ``input.X`` calls (codegen would
23
+ silently emit ``na`` / ``0`` / ``""`` stubs).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Callable
29
+
30
+ from .ast_nodes import (
31
+ ASTNode,
32
+ Program, StrategyDecl,
33
+ VarDecl, Assignment, TupleAssign,
34
+ IfStmt, ForStmt, ForInStmt, WhileStmt, SwitchStmt,
35
+ FuncDef, ExprStmt,
36
+ BinOp, UnaryOp, Ternary, FuncCall, Subscript,
37
+ Identifier, MemberAccess,
38
+ NumberLiteral, StringLiteral, BoolLiteral, NaLiteral, ColorLiteral,
39
+ TupleLiteral,
40
+ TypeDecl, EnumDecl, MethodDef,
41
+ )
42
+ from .errors import SourceLocation, Diagnostic, CompileError, Level, Phase
43
+ from . import signatures as sigs
44
+ from .tv_input_choices import INPUT_SOURCE_SERIES_IDS
45
+ from .analyzer import TA_CLASS_MAP
46
+ from .codegen import (
47
+ MATH_FUNC_MAP, STR_FUNC_MAP,
48
+ ARRAY_METHODS, MAP_METHODS, MATRIX_METHODS,
49
+ SYMINFO_MEMBER_MAP, COLOR_CONST_MAP,
50
+ SKIP_FUNC_NAMES, SKIP_NAMESPACES,
51
+ BAR_BUILTINS, BAR_FIELDS,
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Rule tables
57
+ # ---------------------------------------------------------------------------
58
+
59
+ # `ta.sum` is exposed by TA_CLASS_MAP only as a backing implementation for
60
+ # `math.sum`; it is not a real Pine identifier. Strip from supported set.
61
+ TA_PROPERTY_VARIABLES: frozenset[str] = frozenset(
62
+ {"obv", "accdist", "nvi", "pvi", "pvt", "wad", "wvad", "iii"}
63
+ )
64
+ SUPPORTED_TA: frozenset[str] = frozenset(
65
+ set(TA_CLASS_MAP) - {"sum", "vwap_bands"} - set(TA_PROPERTY_VARIABLES) | {"tr", "pivot_point_levels"}
66
+ )
67
+ SUPPORTED_MATH: frozenset[str] = frozenset(
68
+ set(MATH_FUNC_MAP) | set(sigs.MATH_CONSTANTS) | set(sigs.MATH_FUNCTIONS)
69
+ )
70
+ SUPPORTED_STR: frozenset[str] = frozenset(set(STR_FUNC_MAP) | {"format_time"})
71
+ SUPPORTED_INPUT: frozenset[str] = frozenset(sigs.INPUT_FUNCTIONS)
72
+ SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"})
73
+ SUPPORTED_MAP: frozenset[str] = frozenset(set(MAP_METHODS) | {"new"})
74
+ SUPPORTED_MATRIX: frozenset[str] = frozenset(set(MATRIX_METHODS) | {"new"})
75
+ SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP)
76
+ SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP)
77
+ SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"})
78
+ SUPPORTED_TIMEFRAME_FUNC: frozenset[str] = frozenset({"change", "in_seconds"})
79
+ SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"})
80
+ # log.* helpers wired into pine_log_{info,warning,error} by codegen/visit_call.
81
+ # Without this whitelist, codegen silently emits an empty-string literal
82
+ # (``"" /* unsupported log */``) for unknown log names. Valid C++, but a dead
83
+ # string statement that hides the typo from the strategy author.
84
+ SUPPORTED_LOG: frozenset[str] = frozenset({"info", "warning", "error"})
85
+
86
+ HARD_REJECT_FUNC: dict[str, str] = {
87
+ "request.financial": "External fundamentals data not available in PineForge.",
88
+ "request.dividends": "External corporate-action data not available in PineForge.",
89
+ "request.earnings": "External corporate-action data not available in PineForge.",
90
+ "request.splits": "External corporate-action data not available in PineForge.",
91
+ "request.seed": "External seed data feeds not available in PineForge.",
92
+ "request.quandl": "External Quandl data not available in PineForge.",
93
+ "request.currency_rate": "Currency conversion data not available in PineForge.",
94
+ "color.from_gradient": "Charting helpers not available in PineForge backtests.",
95
+ # ticker.* chart-type modifiers and cross-symbol constructors — hard reject
96
+ "ticker.heikinashi": (
97
+ "ticker.heikinashi() chart-type modifier / cross-symbol construction not supported — "
98
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
99
+ ),
100
+ "ticker.renko": (
101
+ "ticker.renko() chart-type modifier / cross-symbol construction not supported — "
102
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
103
+ ),
104
+ "ticker.kagi": (
105
+ "ticker.kagi() chart-type modifier / cross-symbol construction not supported — "
106
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
107
+ ),
108
+ "ticker.linebreak": (
109
+ "ticker.linebreak() chart-type modifier / cross-symbol construction not supported — "
110
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
111
+ ),
112
+ "ticker.pointfigure": (
113
+ "ticker.pointfigure() chart-type modifier / cross-symbol construction not supported — "
114
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
115
+ ),
116
+ "ticker.new": (
117
+ "ticker.new() chart-type modifier / cross-symbol construction not supported — "
118
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
119
+ ),
120
+ "ticker.modify": (
121
+ "ticker.modify() chart-type modifier / cross-symbol construction not supported — "
122
+ "PineForge runs same-symbol MTF only via request.security with the chart's own tickerid."
123
+ ),
124
+ }
125
+
126
+ # ticker.inherit and ticker.standard are NOT in HARD_REJECT_NAMESPACE;
127
+ # they pass through in codegen (emit the symbol argument unchanged).
128
+ # The remaining ticker.* chart-type modifiers are in HARD_REJECT_FUNC above.
129
+ HARD_REJECT_NAMESPACE: dict[str, str] = {
130
+ # (ticker namespace blanket-reject removed; per-function entries above)
131
+ }
132
+
133
+ # Built-in variables whose PineForge value diverges from TradingView semantics.
134
+ # Demoted to WARNING — many real strategies use bar_index / time_close in
135
+ # logging or visual logic that does not affect trade outcomes. The checker
136
+ # still flags divergence so users see the risk.
137
+ DIVERGENT_VARS: dict[str, str] = {
138
+ "bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
139
+ "last_bar_index": "last_bar_index is incorrectly aliased to the current bar index in PineForge codegen.",
140
+ "timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
141
+ "time_close": "time_close is aliased to the bar open timestamp in PineForge; it does not represent the bar close time.",
142
+ }
143
+
144
+ BARSTATE_APPROX_VARS: dict[str, str] = {
145
+ "barstate.islast": "barstate.islast is always false in PineForge batch backtests.",
146
+ "barstate.ishistory": "barstate.ishistory is always true in PineForge batch backtests.",
147
+ "barstate.isrealtime": "barstate.isrealtime is always false in PineForge batch backtests.",
148
+ "barstate.isnew": "barstate.isnew follows PineForge first-tick execution state.",
149
+ "barstate.isconfirmed": "barstate.isconfirmed follows PineForge last-tick execution state.",
150
+ "barstate.islastconfirmedhistory": "barstate.islastconfirmedhistory is always false in PineForge batch backtests.",
151
+ }
152
+
153
+ STRATEGY_UNSUPPORTED_PARAMS: dict[str, set[str]] = {
154
+ "entry": {"alert_message", "disable_alert"},
155
+ "order": {"comment", "alert_message", "disable_alert", "qty_type"},
156
+ "exit": {"comment_profit", "comment_loss", "comment_trailing", "alert_message", "alert_profit", "alert_loss", "alert_trailing", "disable_alert"},
157
+ "close": {"alert_message", "disable_alert"},
158
+ "close_all": {"comment", "alert_message", "disable_alert", "immediately"},
159
+ }
160
+
161
+ # strategy.closedtrades / strategy.opentrades accessor surfaces are NOT
162
+ # symmetric in Pine v6. opentrades has no exit_* fields (a trade has not
163
+ # closed yet). Both lack ``direction`` (Pine has ``size`` whose sign carries
164
+ # the direction). Splitting the whitelist makes the support checker reject
165
+ # typos like ``strategy.opentrades.exit_price(0)`` that previously slipped
166
+ # through into codegen and produced trade-accessor calls the runtime does
167
+ # not implement on the open-trades side.
168
+ CLOSED_TRADE_ACCESSOR_METHODS: frozenset[str] = frozenset({
169
+ "profit", "profit_percent", "commission",
170
+ "entry_bar_index", "exit_bar_index", "entry_comment", "exit_comment",
171
+ "entry_id", "exit_id", "entry_price", "exit_price", "entry_time",
172
+ "exit_time", "size", "max_runup", "max_runup_percent",
173
+ "max_drawdown", "max_drawdown_percent",
174
+ })
175
+ OPEN_TRADE_ACCESSOR_METHODS: frozenset[str] = frozenset({
176
+ "profit", "profit_percent", "commission",
177
+ "entry_bar_index", "entry_comment", "entry_id",
178
+ "entry_price", "entry_time",
179
+ "size", "max_runup", "max_runup_percent",
180
+ "max_drawdown", "max_drawdown_percent",
181
+ })
182
+ # Back-compat alias for any external consumer still importing the union set
183
+ # (kept as the union of the two new sets so a stale import never silently
184
+ # narrows). New code should prefer the side-specific constant above.
185
+ TRADE_ACCESSOR_METHODS: frozenset[str] = (
186
+ CLOSED_TRADE_ACCESSOR_METHODS | OPEN_TRADE_ACCESSOR_METHODS
187
+ )
188
+
189
+ STRATEGY_EXIT_PRICE_PARAMS: frozenset[str] = frozenset({
190
+ "profit", "loss", "limit", "stop", "trail_price", "trail_points", "trail_offset",
191
+ })
192
+
193
+ # Implementable but currently silent in codegen -> reject loudly.
194
+ NOT_YET_FUNC: dict[str, str] = {
195
+ "max_bars_back": "max_bars_back is silently dropped by the codegen.",
196
+ "timeframe.from_seconds": "timeframe.from_seconds is not yet implemented; codegen would emit 'false' and silently produce wrong TF strings.",
197
+ }
198
+
199
+ # Bare (no-namespace) function names that codegen has no handler for.
200
+ # Without a handler, the generic emitter at visit_call.py:912 would
201
+ # produce e.g. `color(arg)` — an undeclared C++ symbol. Reject loudly.
202
+ UNSUPPORTED_BARE_FUNCS: dict[str, str] = {
203
+ "color": "Bare color(...) cast is not supported. Use color.new(c, alpha) or color.rgb(r, g, b, transp).",
204
+ }
205
+
206
+ # Whole namespaces with NO codegen support. Any call into one of these
207
+ # fails to compile downstream. Reject loudly with a precise message so
208
+ # users don't see a cryptic C++ error referencing an undeclared
209
+ # namespace.
210
+ UNSUPPORTED_NAMESPACES: dict[str, str] = {
211
+ "footprint": "footprint.* (bid/ask volume rows) is not supported in PineForge batch backtests; it requires tick-level data the engine does not consume.",
212
+ "volume_row": "volume_row.* is not supported in PineForge batch backtests; same reason as footprint.*.",
213
+ }
214
+
215
+ # Member-access references with no batch-mode equivalent. Codegen would
216
+ # silently emit "false" (visit_expr.py chart.* fallthrough) which
217
+ # becomes epoch 0 in time arithmetic. Reject loudly.
218
+ UNSUPPORTED_MEMBERS: dict[tuple[str, str], str] = {
219
+ ("chart", "left_visible_bar_time"): "chart.left_visible_bar_time has no meaning in a batch backtest (no viewport).",
220
+ ("chart", "right_visible_bar_time"): "chart.right_visible_bar_time has no meaning in a batch backtest (no viewport).",
221
+ ("chart", "bg_color"): "chart.bg_color has no meaning in a batch backtest (no chart theme).",
222
+ ("chart", "fg_color"): "chart.fg_color has no meaning in a batch backtest (no chart theme).",
223
+ }
224
+
225
+ # Constant-only namespaces whose members are drawing/visual style constants
226
+ # (or call-scoped option constants). They are legitimately consumed as
227
+ # ARGUMENTS to parse-and-skip visual calls (plot, plotshape, hline,
228
+ # label.new, table.cell, ...), inside the strategy() declaration, and — for
229
+ # barmerge.* — as request.security gaps/lookahead values. Outside those
230
+ # contexts codegen falls through to ``std::string("<member>")`` while the
231
+ # analyzer types the read INT: a silent type mismatch that at best surfaces
232
+ # as a cryptic C++ error. Reject loudly when the member access survives into
233
+ # non-visual code paths (see ``_const_arg_ctx_depth``).
234
+ _CONST_NS_VISUAL_MSG = (
235
+ "is a visual/style constant with no runtime value in PineForge "
236
+ "backtests; it is only accepted as an argument to visual calls "
237
+ "(plot, plotshape, hline, label.new, table.cell, ...), which "
238
+ "PineForge parses and skips."
239
+ )
240
+ UNSUPPORTED_CONST_NAMESPACES: dict[str, str] = {
241
+ "extend": _CONST_NS_VISUAL_MSG,
242
+ "font": _CONST_NS_VISUAL_MSG,
243
+ "hline": _CONST_NS_VISUAL_MSG,
244
+ "location": _CONST_NS_VISUAL_MSG,
245
+ "plot": _CONST_NS_VISUAL_MSG,
246
+ "scale": _CONST_NS_VISUAL_MSG,
247
+ "shape": _CONST_NS_VISUAL_MSG,
248
+ "text": _CONST_NS_VISUAL_MSG,
249
+ "xloc": _CONST_NS_VISUAL_MSG,
250
+ "yloc": _CONST_NS_VISUAL_MSG,
251
+ "barmerge": (
252
+ "is only valid as the gaps/lookahead argument of "
253
+ "request.security(...); it has no runtime value as a free "
254
+ "expression in PineForge."
255
+ ),
256
+ "alert": (
257
+ "is only valid as the freq argument of alert(...); it has no "
258
+ "runtime value as a free expression in PineForge."
259
+ ),
260
+ }
261
+
262
+ # Namespaces whose variable members have no batch-mode data source.
263
+ # These reads currently emit `std::string("<member>")` via the
264
+ # unknown-identifier fallthrough — which the analyzer-typed `double`
265
+ # context then rejects at C++ compile time. Catch it earlier.
266
+ UNSUPPORTED_NAMESPACE_VARS: dict[str, str] = {
267
+ "dividends": "dividends.* is not available in PineForge — fundamental dividend data is not loaded.",
268
+ "earnings": "earnings.* is not available in PineForge — fundamental earnings data is not loaded.",
269
+ "splits": "splits.* is not available in PineForge — corporate-action split data is not loaded.",
270
+ }
271
+
272
+ # request.security parameter rules.
273
+ # Codegen supports symbol/timeframe/expression plus gaps/lookahead (read in
274
+ # _eval_security_* emission and forwarded to register_security_eval).
275
+ # ignore_invalid_symbol/currency are accepted by the parser but silently
276
+ # dropped by codegen — reject loudly to surface unsupported behavior.
277
+ SECURITY_ALLOWED_PARAMS: frozenset[str] = frozenset(
278
+ {"symbol", "timeframe", "expression", "gaps", "lookahead",
279
+ # Data-adjustment constants — silently accepted and ignored by codegen;
280
+ # the underlying engine uses a fixed unadjusted data source.
281
+ "backadjustment", "settlement_as_close", "adjustment"}
282
+ )
283
+ SECURITY_PARAM_ORDER: tuple[str, ...] = (
284
+ "symbol", "timeframe", "expression",
285
+ "gaps", "lookahead", "ignore_invalid_symbol", "currency",
286
+ )
287
+ SECURITY_MAX_POSITIONAL: int = 5 # symbol, timeframe, expression, gaps, lookahead
288
+
289
+ # Per-kwarg allowed values for request.security data-adjustment params.
290
+ # Values NOT in this list cause silent wrong-result backtests: codegen
291
+ # emits a numeric constant which the engine ignores entirely, producing
292
+ # a different price series from TradingView with no warning. Reject
293
+ # loudly when the script passes anything outside the no-op set.
294
+ #
295
+ # (off / none = the engine's de-facto behavior; inherit = "follow chart
296
+ # settings" which, in batch mode with a single data source, is also a
297
+ # no-op.)
298
+ SECURITY_ADJUSTMENT_ALLOWED_VALUES: dict[str, frozenset[str]] = {
299
+ "backadjustment": frozenset({"off", "inherit"}),
300
+ "settlement_as_close": frozenset({"off", "inherit"}),
301
+ "adjustment": frozenset({"none", "inherit"}),
302
+ }
303
+ # Identifiers/expressions that resolve to "this script's symbol".
304
+ SECURITY_CURRENT_SYMBOL_NAMES: frozenset[str] = frozenset({"syminfo.tickerid", "syminfo.ticker"})
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Helpers
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def _loc(node: ASTNode | None, fallback_file: str) -> SourceLocation:
312
+ if node is not None and getattr(node, "loc", None) is not None:
313
+ return node.loc
314
+ return SourceLocation(file=fallback_file, line=1, col=1, end_col=1)
315
+
316
+
317
+ def _qualified_name(callee: ASTNode) -> tuple[str | None, str | None]:
318
+ """Return (namespace, function_name) for a call's callee, or (None, None)."""
319
+ if isinstance(callee, Identifier):
320
+ return None, callee.name
321
+ if isinstance(callee, MemberAccess):
322
+ obj = callee.object
323
+ # Single-level: ns.func
324
+ if isinstance(obj, Identifier):
325
+ return obj.name, callee.member
326
+ # Multi-level: foo.bar.baz - flatten left side
327
+ if isinstance(obj, MemberAccess):
328
+ left_ns, left_name = _qualified_name(obj)
329
+ if left_ns is None and left_name is not None:
330
+ return left_name, callee.member
331
+ if left_ns is not None and left_name is not None:
332
+ return f"{left_ns}.{left_name}", callee.member
333
+ return None, None
334
+
335
+
336
+ def _resolve_member_chain(node: ASTNode) -> str | None:
337
+ """Flatten a MemberAccess/Identifier chain into a dotted name."""
338
+ if isinstance(node, Identifier):
339
+ return node.name
340
+ if isinstance(node, MemberAccess):
341
+ left = _resolve_member_chain(node.object)
342
+ if left is None:
343
+ return None
344
+ return f"{left}.{node.member}"
345
+ return None
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Checker
350
+ # ---------------------------------------------------------------------------
351
+
352
+ class SupportChecker:
353
+ """AST walker that produces Diagnostic objects for unsupported features."""
354
+
355
+ # syminfo fields that emit na<T>() (or route through the runtime metadata
356
+ # map, which returns na until a feed injects a value) due to missing data;
357
+ # warn when used in conditional context (if-condition or ternary
358
+ # condition) because the condition will always evaluate to false/na.
359
+ # Derived from the emission table so new na-accept fields cannot drift
360
+ # out of the warning: every SYMINFO_MEMBER_MAP entry whose emission is an
361
+ # ``na<T>()`` literal or a ``get_syminfo_metadata(...)`` lookup is a
362
+ # silent gap (sector/industry/isin/expiration_date/current_contract/
363
+ # mincontract/root/pricescale/minmove + employees/shareholders/
364
+ # shares_outstanding_*/recommendations_*/target_price_*).
365
+ _SYMINFO_SILENT_GAP_FIELDS: frozenset[str] = frozenset(
366
+ member
367
+ for member, emission in SYMINFO_MEMBER_MAP.items()
368
+ if "na<" in emission or "get_syminfo_metadata" in emission
369
+ )
370
+
371
+ def __init__(self, ast: Program, filename: str = "<input>") -> None:
372
+ self._ast = ast
373
+ self._filename = filename
374
+ self._diagnostics: list[Diagnostic] = []
375
+ # Names defined locally (UDTs, enums, user functions, methods) so we
376
+ # don't flag user-defined identifiers as unknown built-ins.
377
+ self._user_types: set[str] = set()
378
+ self._user_enums: set[str] = set()
379
+ self._user_funcs: set[str] = set()
380
+ self._user_methods: set[str] = set()
381
+ # Track whether we are inside an if/ternary condition expression.
382
+ self._in_conditional_depth: int = 0
383
+ # Track whether we are inside an argument subtree that legitimately
384
+ # consumes constant-namespace members: parse-and-skip visual calls
385
+ # (plot, label.new, ...), the strategy() declaration, and
386
+ # request.security (barmerge.* gaps/lookahead values). While > 0 the
387
+ # UNSUPPORTED_CONST_NAMESPACES rejection is suppressed.
388
+ self._const_arg_ctx_depth: int = 0
389
+
390
+ # -- Public API --
391
+
392
+ def check(self) -> list[Diagnostic]:
393
+ self._collect_user_definitions(self._ast)
394
+ for stmt in self._ast.body:
395
+ self._visit(stmt)
396
+ return self._diagnostics
397
+
398
+ def check_or_raise(self) -> None:
399
+ diags = self.check()
400
+ errors = [d for d in diags if d.level == Level.ERROR]
401
+ if errors:
402
+ raise CompileError(diags)
403
+
404
+ # -- Setup --
405
+
406
+ def _collect_user_definitions(self, ast: Program) -> None:
407
+ for stmt in ast.body:
408
+ if isinstance(stmt, TypeDecl):
409
+ self._user_types.add(stmt.name)
410
+ elif isinstance(stmt, EnumDecl):
411
+ self._user_enums.add(stmt.name)
412
+ elif isinstance(stmt, FuncDef):
413
+ self._user_funcs.add(stmt.name)
414
+ elif isinstance(stmt, MethodDef):
415
+ self._user_methods.add(stmt.name)
416
+
417
+ # -- Diagnostic emission --
418
+
419
+ def _err(self, node: ASTNode | None, message: str, hint: str | None = None) -> None:
420
+ self._diagnostics.append(Diagnostic(
421
+ level=Level.ERROR,
422
+ phase=Phase.ANALYZER,
423
+ location=_loc(node, self._filename),
424
+ message=message,
425
+ hint=hint,
426
+ ))
427
+
428
+ def _warn(self, node: ASTNode | None, message: str, hint: str | None = None) -> None:
429
+ self._diagnostics.append(Diagnostic(
430
+ level=Level.WARNING,
431
+ phase=Phase.ANALYZER,
432
+ location=_loc(node, self._filename),
433
+ message=message,
434
+ hint=hint,
435
+ ))
436
+
437
+ def _reject_if_in(
438
+ self,
439
+ table: dict,
440
+ key,
441
+ node: ASTNode,
442
+ msg_fmt: Callable[[object, str], str],
443
+ hint: str | None = None,
444
+ ) -> bool:
445
+ """Look ``key`` up in ``table``; if present, emit an error built from
446
+ ``msg_fmt(key, table[key])``, visit children, and return True. Otherwise
447
+ return False so the caller can fall through to later checks.
448
+
449
+ Consolidates the near-identical lookup+emit blocks that dispatch the
450
+ UNSUPPORTED_* tables in _visit_FuncCall / _visit_MemberAccess.
451
+ """
452
+ if key not in table:
453
+ return False
454
+ self._err(node, msg_fmt(key, table[key]), hint=hint)
455
+ self._visit_children(node)
456
+ return True
457
+
458
+ # -- Visitor dispatch --
459
+
460
+ def _visit(self, node: ASTNode | None) -> None:
461
+ if node is None:
462
+ return
463
+ method = getattr(self, f"_visit_{type(node).__name__}", None)
464
+ if method is not None:
465
+ method(node)
466
+ return
467
+ self._visit_children(node)
468
+
469
+ def _visit_children_const_ok(self, node: ASTNode) -> None:
470
+ """Visit children with constant-namespace member reads allowed.
471
+
472
+ Used for the argument subtrees of parse-and-skip visual calls,
473
+ the strategy() declaration, and request.security — the contexts
474
+ where ``plot.style_*`` / ``text.align_*`` / ``barmerge.*`` /
475
+ ``alert.freq_*`` constants are legitimate.
476
+ """
477
+ self._const_arg_ctx_depth += 1
478
+ try:
479
+ self._visit_children(node)
480
+ finally:
481
+ self._const_arg_ctx_depth -= 1
482
+
483
+ def _visit_children(self, node: ASTNode) -> None:
484
+ for value in vars(node).values():
485
+ if isinstance(value, ASTNode):
486
+ self._visit(value)
487
+ elif isinstance(value, list):
488
+ for item in value:
489
+ if isinstance(item, ASTNode):
490
+ self._visit(item)
491
+ elif isinstance(value, dict):
492
+ for item in value.values():
493
+ if isinstance(item, ASTNode):
494
+ self._visit(item)
495
+
496
+ # -- Specific visitors --
497
+
498
+ @staticmethod
499
+ def _param_is_provided(node: FuncCall, namespace: str | None, func_name: str, param_name: str) -> bool:
500
+ if param_name in node.kwargs:
501
+ return True
502
+ param_names = sigs.get_param_names(namespace, func_name) or []
503
+ try:
504
+ idx = param_names.index(param_name)
505
+ except ValueError:
506
+ return False
507
+ return idx < len(node.args)
508
+
509
+ @staticmethod
510
+ def _is_color_constant_or_builder(expr: ASTNode) -> bool:
511
+ """True when ``expr`` is a Pine color literal/builder safe to feed
512
+ into the codegen's packed-int input route.
513
+
514
+ Accepts:
515
+ * ``color.<const>`` (e.g. ``color.red``)
516
+ * ``color.new(...)`` / ``color.rgb(...)`` builder calls
517
+ * ``ColorLiteral`` (#rrggbb syntax)
518
+ """
519
+ if isinstance(expr, ColorLiteral):
520
+ return True
521
+ if isinstance(expr, MemberAccess) and isinstance(expr.object, Identifier):
522
+ if expr.object.name == "color":
523
+ return True
524
+ if isinstance(expr, FuncCall):
525
+ ns, name = _qualified_name(expr.callee)
526
+ if ns == "color" and name in ("new", "rgb"):
527
+ return True
528
+ return False
529
+
530
+ def _check_input_color_defval(self, node: FuncCall) -> None:
531
+ """input.color: reject defvals that aren't a color constant/builder."""
532
+ defval: ASTNode | None = None
533
+ if node.args:
534
+ defval = node.args[0]
535
+ elif "defval" in node.kwargs:
536
+ defval = node.kwargs["defval"]
537
+ if defval is None:
538
+ self._err(
539
+ node,
540
+ "input.color(...) requires a color defval "
541
+ "(e.g. color.red or color.new(color.red, 50)).",
542
+ )
543
+ return
544
+ if not self._is_color_constant_or_builder(defval):
545
+ self._err(
546
+ node,
547
+ "input.color(...) defval must be a color constant "
548
+ "(color.red, ...), a color literal (#rrggbb), or a "
549
+ "color.new(...) / color.rgb(...) builder. Arbitrary "
550
+ "expressions are not supported: the engine has no color "
551
+ "helper, so codegen routes input.color through the int "
552
+ "getter — any non-color defval would silently store a "
553
+ "numeric value with no color encoding.",
554
+ hint="Replace the defval with color.<name> or color.new(...).",
555
+ )
556
+
557
+ def _check_input_source_defval(self, node: FuncCall) -> None:
558
+ """input.source: reject defvals that aren't a native chart series.
559
+
560
+ PineForge restricts input.source strictly to native OHLCV series
561
+ (open/high/low/close/volume/hl2/hlc3/ohlc4/hlcc4) — the engine's
562
+ runtime override (get_input_source) can only resolve to those base
563
+ series. User series, computed expressions, and indicator outputs
564
+ have no resolvable backing series; without this guard codegen would
565
+ bind to the close fallback, silently using the wrong series."""
566
+ defval: ASTNode | None = None
567
+ if node.args:
568
+ defval = node.args[0]
569
+ elif "defval" in node.kwargs:
570
+ defval = node.kwargs["defval"]
571
+ if defval is None:
572
+ self._err(
573
+ node,
574
+ "input.source(...) requires a native series defval "
575
+ "(close, open, high, low, hl2, hlc3, ohlc4, ...).",
576
+ )
577
+ return
578
+ if not (isinstance(defval, Identifier)
579
+ and defval.name in INPUT_SOURCE_SERIES_IDS):
580
+ self._err(
581
+ node,
582
+ "input.source(...) defval must be a native chart series "
583
+ "(open, high, low, close, volume, hl2, hlc3, ohlc4, hlcc4). "
584
+ "User series, computed expressions, and indicator outputs "
585
+ "are not supported: input.source is restricted to native "
586
+ "OHLCV series.",
587
+ hint="Use one of: close, open, high, low, hl2, hlc3, ohlc4.",
588
+ )
589
+
590
+ def _visit_StrategyDecl(self, node: StrategyDecl) -> None:
591
+ kind = (node.annotations or {}).get("decl_kind", "strategy")
592
+ if kind != "strategy":
593
+ self._err(
594
+ node,
595
+ f"{kind}() declarations are not supported; PineForge runs strategies only.",
596
+ hint="Replace the declaration with strategy(...) and add explicit entry/exit calls.",
597
+ )
598
+ # strategy(...) kwargs legitimately carry constant-namespace members
599
+ # (e.g. scale=scale.right, format=format.price).
600
+ self._visit_children_const_ok(node)
601
+
602
+ def _visit_VarDecl(self, node: VarDecl) -> None:
603
+ if node.is_varip:
604
+ self._err(
605
+ node,
606
+ "varip is not supported in PineForge batch backtests — there "
607
+ "are no intrabar ticks. Codegen would silently demote varip "
608
+ "to var, producing incorrect state accumulation for any "
609
+ "script that relies on tick-level updates.",
610
+ hint=(
611
+ "Replace 'varip' with 'var' if the strategy logic does "
612
+ "not depend on tick-level updates, or run the strategy "
613
+ "in hosted TradingView Studio."
614
+ ),
615
+ )
616
+ self._visit_children(node)
617
+
618
+ def _visit_TupleAssign(self, node: TupleAssign) -> None:
619
+ if isinstance(node.value, FuncCall):
620
+ ns, name = _qualified_name(node.value.callee)
621
+ if ns == "ta" and name == "stoch":
622
+ self._err(
623
+ node.value,
624
+ "ta.stoch(...) returns a single series in PineForge; tuple destructuring is not supported.",
625
+ hint="Use k = ta.stoch(...) and compute smoothing separately if needed.",
626
+ )
627
+ self._visit_children(node)
628
+
629
+ def _visit_FuncCall(self, node: FuncCall) -> None:
630
+ ns, name = _qualified_name(node.callee)
631
+
632
+ if ns is None and name is None:
633
+ self._visit_children(node)
634
+ return
635
+
636
+ full = f"{ns}.{name}" if ns else name
637
+
638
+ # Hard rejects by full name.
639
+ if full in HARD_REJECT_FUNC:
640
+ self._err(node, f"{full}(...) is not supported.", hint=HARD_REJECT_FUNC[full])
641
+ self._visit_children(node)
642
+ return
643
+
644
+ # Hard rejects by namespace (e.g. ticker.*).
645
+ if ns is not None and ns in HARD_REJECT_NAMESPACE:
646
+ self._err(
647
+ node,
648
+ f"{full}(...) is not supported.",
649
+ hint=HARD_REJECT_NAMESPACE[ns],
650
+ )
651
+ self._visit_children(node)
652
+ return
653
+
654
+ # Not-yet-implemented. Check qualified name (e.g. "timeframe.from_seconds")
655
+ # first, then bare name (e.g. "max_bars_back") for back-compat entries.
656
+ if full in NOT_YET_FUNC:
657
+ self._err(node, f"{full}(...) is not implemented yet.", hint=NOT_YET_FUNC[full])
658
+ self._visit_children(node)
659
+ return
660
+ if ns is not None and name in NOT_YET_FUNC:
661
+ self._err(node, f"{name}(...) is not implemented yet.", hint=NOT_YET_FUNC[name])
662
+ self._visit_children(node)
663
+ return
664
+
665
+ # Bare-function rejections (e.g. `color(arg)` cast). Codegen would
666
+ # otherwise fall through to the generic emit at visit_call.py:912 and
667
+ # produce an undeclared C++ symbol.
668
+ if ns is None and self._reject_if_in(
669
+ UNSUPPORTED_BARE_FUNCS,
670
+ name,
671
+ node,
672
+ lambda k, v: f"{k}(...): {v}",
673
+ hint="See https://www.tradingview.com/pine-script-docs/concepts/colors/ for the supported color builders.",
674
+ ):
675
+ return
676
+
677
+ # Whole-namespace rejections (e.g. footprint.*, volume_row.*).
678
+ if ns is not None and self._reject_if_in(
679
+ UNSUPPORTED_NAMESPACES,
680
+ ns,
681
+ node,
682
+ lambda k, v: f"{full}: {v}",
683
+ hint="Remove the call or replace it with native OHLCV-based logic.",
684
+ ):
685
+ return
686
+
687
+ # Bare barssince() — codegen emits 0 (broken).
688
+ if ns is None and name == "library":
689
+ self._err(
690
+ node,
691
+ "library() declarations are not supported; PineForge runs strategies only.",
692
+ hint="Use strategy(...) and inline or pre-expand library code before uploading.",
693
+ )
694
+ self._visit_children(node)
695
+ return
696
+
697
+ # Bare barssince() — codegen emits 0 (broken).
698
+ if ns is None and name == "barssince":
699
+ self._err(
700
+ node,
701
+ "Bare barssince(...) is broken in PineForge codegen.",
702
+ hint="Use ta.barssince(...) instead.",
703
+ )
704
+ self._visit_children(node)
705
+ return
706
+
707
+ # ta.sum — not a real Pine identifier.
708
+ if ns == "ta" and name == "sum":
709
+ self._err(
710
+ node,
711
+ "ta.sum is not a PineScript v6 function.",
712
+ hint="Use math.sum(...) instead.",
713
+ )
714
+ self._visit_children(node)
715
+ return
716
+
717
+ if ns == "ta" and name in TA_PROPERTY_VARIABLES:
718
+ self._err(
719
+ node,
720
+ f"ta.{name} is a PineScript v6 variable, not a function.",
721
+ hint=f"Use ta.{name} without parentheses.",
722
+ )
723
+ self._visit_children(node)
724
+ return
725
+
726
+ if ns == "strategy.closedtrades":
727
+ if name not in CLOSED_TRADE_ACCESSOR_METHODS:
728
+ self._err(node, f"{full}(...) is not implemented in PineForge runtime.")
729
+ self._visit_children(node)
730
+ return
731
+ if ns == "strategy.opentrades":
732
+ if name not in OPEN_TRADE_ACCESSOR_METHODS:
733
+ hint = None
734
+ if name in CLOSED_TRADE_ACCESSOR_METHODS:
735
+ hint = (
736
+ f"strategy.opentrades has no '{name}' accessor in Pine v6 "
737
+ f"(it only exists on strategy.closedtrades)."
738
+ )
739
+ self._err(node, f"{full}(...) is not implemented in PineForge runtime.", hint=hint)
740
+ self._visit_children(node)
741
+ return
742
+
743
+ if ns == "strategy" and name not in sigs.STRATEGY_FUNCTIONS:
744
+ self._err(node, f"strategy.{name}(...) is not implemented in PineForge runtime.")
745
+ self._visit_children(node)
746
+ return
747
+
748
+ if ns == "strategy" and name == "exit":
749
+ has_price_param = any(
750
+ self._param_is_provided(node, "strategy", "exit", param)
751
+ for param in STRATEGY_EXIT_PRICE_PARAMS
752
+ )
753
+ if not has_price_param:
754
+ self._err(
755
+ node,
756
+ "strategy.exit(...) requires at least one price or trailing parameter in PineForge.",
757
+ hint="Use strategy.close(...) for market exits.",
758
+ )
759
+
760
+ # request.security strictness. Children are visited with constant-
761
+ # namespace reads allowed: barmerge.* gaps/lookahead values are
762
+ # validated above by _check_request_security and consumed by codegen.
763
+ if full == "request.security":
764
+ self._check_request_security(node)
765
+ self._visit_children_const_ok(node)
766
+ return
767
+ # request.security_lower_tf — analyzer/codegen handle parameter validation
768
+ # and element-type rejection (UDT/color/string). Still validate the
769
+ # timeframe literal here so codegen catches malformed TF strings early.
770
+ if full == "request.security_lower_tf":
771
+ self._check_request_security_lower_tf_tf(node)
772
+ self._visit_children_const_ok(node)
773
+ return
774
+ if ns == "request":
775
+ self._err(
776
+ node,
777
+ f"{full}(...) is not supported. Only request.security(...) and request.security_lower_tf(...) are available in PineForge.",
778
+ hint="External request feeds and other request.* variants are intentionally unavailable.",
779
+ )
780
+ self._visit_children(node)
781
+ return
782
+
783
+ # strategy.risk.* is partially supported by codegen/runtime for common
784
+ # risk limits. Warn because TradingView risk semantics are broad and
785
+ # not every edge case is modeled exactly.
786
+ if ns == "strategy.risk":
787
+ self._warn(
788
+ node,
789
+ f"strategy.risk.{name}(...) has partial PineForge runtime support.",
790
+ hint="Verify risk behavior against PineForge results; unsupported risk edge cases may diverge from TradingView.",
791
+ )
792
+ self._visit_children(node)
793
+ return
794
+
795
+ if ns == "strategy" and name in STRATEGY_UNSUPPORTED_PARAMS:
796
+ for param_name in sorted(STRATEGY_UNSUPPORTED_PARAMS[name]):
797
+ if self._param_is_provided(node, "strategy", name, param_name):
798
+ warn_node = node.kwargs.get(param_name, node)
799
+ self._warn(
800
+ warn_node,
801
+ f"strategy.{name} parameter '{param_name}' is not supported by PineForge and is ignored.",
802
+ )
803
+
804
+ # Unknown ta.* / math.* / str.* / input.* - codegen emits silent stubs.
805
+ if ns == "ta" and name not in SUPPORTED_TA:
806
+ self._err(node, f"ta.{name}(...) is not implemented in PineForge runtime.")
807
+ self._visit_children(node)
808
+ return
809
+ if ns == "math" and name not in SUPPORTED_MATH:
810
+ self._err(node, f"math.{name}(...) is not implemented in PineForge runtime.")
811
+ self._visit_children(node)
812
+ return
813
+ if ns == "str" and name not in SUPPORTED_STR:
814
+ self._err(node, f"str.{name}(...) is not implemented in PineForge runtime.")
815
+ self._visit_children(node)
816
+ return
817
+ if ns == "input" and name not in SUPPORTED_INPUT:
818
+ self._err(node, f"input.{name}(...) is not implemented in PineForge runtime.")
819
+ self._visit_children(node)
820
+ return
821
+ if ns == "input" and name == "color":
822
+ # The engine has no get_input_color helper; codegen routes
823
+ # input.color through get_input_int with the defval emitted as
824
+ # a packed RGBA int. That is only meaningful when the defval
825
+ # itself is a color literal or builder. An arbitrary int/identifier
826
+ # would silently bind a numeric value with no color encoding,
827
+ # producing wrong-colored UI (or worse, ambiguous state if the
828
+ # value is ever passed back to a color-aware sink).
829
+ self._check_input_color_defval(node)
830
+ if ns == "input" and name == "source":
831
+ # input.source is restricted to native OHLCV series — the engine
832
+ # can only resolve a runtime override to a native base series.
833
+ self._check_input_source_defval(node)
834
+ if ns == "timeframe" and name not in SUPPORTED_TIMEFRAME_FUNC:
835
+ self._err(node, f"timeframe.{name}(...) is not implemented in PineForge runtime.")
836
+ self._visit_children(node)
837
+ return
838
+ if ns == "color" and name not in SUPPORTED_COLOR_FUNC:
839
+ self._err(node, f"color.{name}(...) is not implemented in PineForge runtime.")
840
+ self._visit_children(node)
841
+ return
842
+ if ns == "runtime" and name not in SUPPORTED_RUNTIME_FUNC:
843
+ self._err(node, f"runtime.{name}(...) is not implemented in PineForge runtime.")
844
+ self._visit_children(node)
845
+ return
846
+ if ns == "log" and name not in SUPPORTED_LOG:
847
+ self._err(node, f"log.{name}(...) is not implemented in PineForge runtime.")
848
+ self._visit_children(node)
849
+ return
850
+ if ns == "array" and name not in SUPPORTED_ARRAY:
851
+ self._err(node, f"array.{name}(...) is not implemented in PineForge runtime.")
852
+ self._visit_children(node)
853
+ return
854
+ if ns == "map" and name not in SUPPORTED_MAP:
855
+ self._err(node, f"map.{name}(...) is not implemented in PineForge runtime.")
856
+ self._visit_children(node)
857
+ return
858
+ if ns == "map" and name == "new":
859
+ targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or []
860
+ targs = [str(t).replace(" ", "") for t in targs]
861
+ if targs and targs[0] != "string":
862
+ self._err(node, "map keys must be string in PineForge's supported map subset.")
863
+ if len(targs) > 1 and targs[1] not in {"float", "int", "bool", "string"}:
864
+ self._err(node, "map values must be primitive in PineForge's supported map subset.")
865
+ if ns == "matrix" and name == "new":
866
+ targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or []
867
+ targs = [str(t).replace(" ", "") for t in targs]
868
+ if targs:
869
+ t = targs[0]
870
+ if "<" in t:
871
+ self._err(node, f"matrix<{t}>: nested collection element types not supported in v1.")
872
+ self._visit_children(node)
873
+ return
874
+ allowed_prim = {"float", "int", "bool", "string", "color"}
875
+ if t not in allowed_prim and t not in self._user_types:
876
+ self._err(node, f"matrix<{t}> element type not supported. Allowed: float, int, bool, string, color, or a declared UDT.")
877
+ self._visit_children(node)
878
+ return
879
+ if ns == "matrix" and name not in SUPPORTED_MATRIX:
880
+ self._err(node, f"matrix.{name}(...) is not implemented in PineForge runtime.")
881
+ self._visit_children(node)
882
+ return
883
+
884
+ # Drawing / charting / alert namespaces — codegen drops silently. Warn,
885
+ # don't error: many strategies include these for the TradingView UI.
886
+ # Their argument subtrees legitimately carry constant-namespace
887
+ # members (plot.style_*, text.align_*, alert.freq_*, ...), so visit
888
+ # children with those reads allowed.
889
+ if ns is None and name in SKIP_FUNC_NAMES:
890
+ self._warn(
891
+ node,
892
+ f"{name}(...) has no effect in PineForge backtests (visual only).",
893
+ )
894
+ self._visit_children_const_ok(node)
895
+ return
896
+ if ns is not None and ns in SKIP_NAMESPACES:
897
+ self._warn(
898
+ node,
899
+ f"{full}(...) has no effect in PineForge backtests (visual only).",
900
+ )
901
+ self._visit_children_const_ok(node)
902
+ return
903
+
904
+ self._visit_children(node)
905
+
906
+ def _visit_Identifier(self, node: Identifier) -> None:
907
+ # ``export`` is a Pine v6 library keyword the lexer treats as a plain
908
+ # identifier. Library scripts already reject at library(); a stray
909
+ # ``export`` in a strategy script would otherwise only die at the
910
+ # codegen unknown-read guard with a generic message.
911
+ if node.name == "export":
912
+ self._err(
913
+ node,
914
+ "'export' is a Pine library keyword; PineForge runs "
915
+ "strategies only and does not support exported "
916
+ "functions/types.",
917
+ hint="Remove the 'export' keyword (and inline any library "
918
+ "code into the strategy script).",
919
+ )
920
+ return
921
+ if node.name in DIVERGENT_VARS:
922
+ self._warn(
923
+ node,
924
+ f"{node.name} diverges from TradingView semantics in PineForge.",
925
+ hint=DIVERGENT_VARS[node.name],
926
+ )
927
+
928
+ def _visit_IfStmt(self, node: IfStmt) -> None:
929
+ """Visit if-statement; mark the condition as conditional context."""
930
+ self._in_conditional_depth += 1
931
+ self._visit(node.condition)
932
+ self._in_conditional_depth -= 1
933
+ for stmt in node.body:
934
+ self._visit(stmt)
935
+ for stmt in node.else_body:
936
+ self._visit(stmt)
937
+
938
+ def _visit_Ternary(self, node: Ternary) -> None:
939
+ """Visit ternary; mark the condition expression as conditional context."""
940
+ self._in_conditional_depth += 1
941
+ self._visit(node.condition)
942
+ self._in_conditional_depth -= 1
943
+ self._visit(node.true_val)
944
+ self._visit(node.false_val)
945
+
946
+ def _visit_MemberAccess(self, node: MemberAccess) -> None:
947
+ chain = _resolve_member_chain(node)
948
+ if chain is not None and chain in DIVERGENT_VARS:
949
+ self._warn(
950
+ node,
951
+ f"{chain} diverges from TradingView semantics in PineForge.",
952
+ hint=DIVERGENT_VARS[chain],
953
+ )
954
+ if chain is not None and chain in BARSTATE_APPROX_VARS:
955
+ self._warn(
956
+ node,
957
+ f"{chain} is approximated in PineForge.",
958
+ hint=BARSTATE_APPROX_VARS[chain],
959
+ )
960
+ # Whole-namespace rejections via member access (e.g. footprint.SomeType).
961
+ if isinstance(node.object, Identifier) and self._reject_if_in(
962
+ UNSUPPORTED_NAMESPACES,
963
+ node.object.name,
964
+ node,
965
+ lambda k, v: f"{k}.{node.member}: {v}",
966
+ ):
967
+ return
968
+ # Specific unsupported (namespace, member) pairs (e.g. chart.left_visible_bar_time).
969
+ if isinstance(node.object, Identifier) and self._reject_if_in(
970
+ UNSUPPORTED_MEMBERS,
971
+ (node.object.name, node.member),
972
+ node,
973
+ lambda k, v: f"{k[0]}.{k[1]}: {v}",
974
+ ):
975
+ return
976
+ # Namespace-wide variable rejections (e.g. dividends.*, earnings.*).
977
+ if isinstance(node.object, Identifier) and self._reject_if_in(
978
+ UNSUPPORTED_NAMESPACE_VARS,
979
+ node.object.name,
980
+ node,
981
+ lambda k, v: f"{k}.{node.member}: {v}",
982
+ ):
983
+ return
984
+ # Constant-only namespace members (plot.style_*, text.align_*,
985
+ # barmerge.*, alert.freq_*, ...) used as FREE EXPRESSIONS. Inside
986
+ # parse-and-skip visual call arguments / strategy() / request.security
987
+ # the read is legitimate and ``_const_arg_ctx_depth`` suppresses this.
988
+ if (
989
+ self._const_arg_ctx_depth == 0
990
+ and isinstance(node.object, Identifier)
991
+ and self._reject_if_in(
992
+ UNSUPPORTED_CONST_NAMESPACES,
993
+ node.object.name,
994
+ node,
995
+ lambda k, v: f"{k}.{node.member} {v}",
996
+ hint=(
997
+ "Constant-namespace members cannot flow into strategy "
998
+ "logic; codegen would emit a string literal while the "
999
+ "analyzer types the read as int."
1000
+ ),
1001
+ )
1002
+ ):
1003
+ return
1004
+ if isinstance(node.object, Identifier) and node.object.name == "syminfo":
1005
+ if node.member not in SUPPORTED_SYMINFO:
1006
+ self._err(node, f"syminfo.{node.member} is not implemented in PineForge runtime.")
1007
+ elif (
1008
+ self._in_conditional_depth > 0
1009
+ and node.member in self._SYMINFO_SILENT_GAP_FIELDS
1010
+ ):
1011
+ self._warn(
1012
+ node,
1013
+ f"syminfo.{node.member} returns na in current PineForge; "
1014
+ "condition will always be false. "
1015
+ "Will be backfilled by pineforge-data product.",
1016
+ )
1017
+ self._visit_children(node)
1018
+
1019
+ # -- request.security parameter rules --
1020
+
1021
+ def _check_request_security(self, node: FuncCall) -> None:
1022
+ """Validate request.security: symbol/timeframe/expression/gaps/lookahead.
1023
+
1024
+ Rejects ignore_invalid_symbol/currency (codegen drops silently).
1025
+ Symbol must reference current chart symbol.
1026
+ gaps/lookahead must be barmerge.{gaps,lookahead}_{on,off} member access
1027
+ (codegen only recognizes that shape; anything else silently dropped).
1028
+ """
1029
+ allowed_hint = (
1030
+ "Only symbol, timeframe, expression, gaps, and lookahead are accepted."
1031
+ )
1032
+
1033
+ # Disallowed kwargs.
1034
+ for kw_name in node.kwargs:
1035
+ if kw_name not in SECURITY_ALLOWED_PARAMS:
1036
+ self._err(
1037
+ node,
1038
+ f"request.security parameter '{kw_name}' is not allowed in PineForge.",
1039
+ hint=allowed_hint,
1040
+ )
1041
+
1042
+ # Disallowed positional args (6th onward = ignore_invalid_symbol/currency).
1043
+ if len(node.args) > SECURITY_MAX_POSITIONAL:
1044
+ for extra in node.args[SECURITY_MAX_POSITIONAL:]:
1045
+ self._err(
1046
+ extra,
1047
+ "Extra positional arguments to request.security are not allowed in PineForge.",
1048
+ hint=allowed_hint,
1049
+ )
1050
+
1051
+ # Symbol-must-be-current check.
1052
+ symbol_node = None
1053
+ if node.args:
1054
+ symbol_node = node.args[0]
1055
+ elif "symbol" in node.kwargs:
1056
+ symbol_node = node.kwargs["symbol"]
1057
+
1058
+ if symbol_node is not None and not self._is_current_symbol_expr(symbol_node):
1059
+ self._err(
1060
+ symbol_node,
1061
+ "request.security symbol must reference the current chart symbol.",
1062
+ hint="Use syminfo.tickerid or syminfo.ticker; PineForge backtests do not load alternate symbols.",
1063
+ )
1064
+
1065
+ # timeframe literal-format check (positional [1] or kwarg).
1066
+ tf_node = node.kwargs.get("timeframe")
1067
+ if tf_node is None and len(node.args) > 1:
1068
+ tf_node = node.args[1]
1069
+ self._check_tf_literal(tf_node, "request.security")
1070
+
1071
+ # gaps / lookahead value-shape check (positional or kwarg).
1072
+ gaps_node = node.kwargs.get("gaps")
1073
+ if gaps_node is None and len(node.args) > 3:
1074
+ gaps_node = node.args[3]
1075
+ if gaps_node is not None and not self._is_barmerge_member(gaps_node, "gaps_on", "gaps_off"):
1076
+ self._err(
1077
+ gaps_node,
1078
+ "request.security gaps must be barmerge.gaps_on or barmerge.gaps_off.",
1079
+ hint="Codegen only recognizes the barmerge.gaps_* literal; other values are silently treated as gaps_off.",
1080
+ )
1081
+
1082
+ lookahead_node = node.kwargs.get("lookahead")
1083
+ if lookahead_node is None and len(node.args) > 4:
1084
+ lookahead_node = node.args[4]
1085
+ if lookahead_node is not None and not self._is_barmerge_member(
1086
+ lookahead_node, "lookahead_on", "lookahead_off"
1087
+ ):
1088
+ self._err(
1089
+ lookahead_node,
1090
+ "request.security lookahead must be barmerge.lookahead_on or barmerge.lookahead_off.",
1091
+ hint="Codegen only recognizes the barmerge.lookahead_* literal; other values are silently treated as lookahead_off.",
1092
+ )
1093
+ if self._is_barmerge_member(lookahead_node, "lookahead_on"):
1094
+ self._err(
1095
+ lookahead_node,
1096
+ "request.security lookahead_on is not supported in PineForge paid parity mode.",
1097
+ hint="Use barmerge.lookahead_off. lookahead_on can expose future/partial HTF values and is highly data-sensitive.",
1098
+ )
1099
+
1100
+ # Data-adjustment kwargs: codegen emits a numeric constant but the
1101
+ # engine ignores it entirely. Only the no-op values are honored
1102
+ # implicitly; reject anything else loudly to surface the silent-
1103
+ # wrong-result bug. See SECURITY_ADJUSTMENT_ALLOWED_VALUES.
1104
+ self._check_security_adjustment_kwargs(node)
1105
+
1106
+ def _check_security_adjustment_kwargs(self, node: FuncCall) -> None:
1107
+ """Reject request.security adjustment kwargs that the engine drops."""
1108
+ for kw_name, allowed in SECURITY_ADJUSTMENT_ALLOWED_VALUES.items():
1109
+ if kw_name not in node.kwargs:
1110
+ continue
1111
+ val = node.kwargs[kw_name]
1112
+ # Expected shape: ``<kw_name>.<member>`` MemberAccess.
1113
+ if isinstance(val, MemberAccess) and isinstance(val.object, Identifier):
1114
+ if val.object.name != kw_name or val.member not in allowed:
1115
+ self._err(
1116
+ val,
1117
+ f"request.security: {kw_name}={val.object.name}.{val.member} "
1118
+ f"is not supported. The engine ignores this kwarg, which "
1119
+ f"would silently produce different prices from TradingView.",
1120
+ hint=f"Allowed values: {sorted(allowed)}.",
1121
+ )
1122
+ else:
1123
+ self._err(
1124
+ val,
1125
+ f"request.security: {kw_name}=... must be a constant member "
1126
+ f"access of the form {kw_name}.<value> "
1127
+ f"(got a non-constant expression).",
1128
+ hint=f"Allowed values: {sorted(allowed)}.",
1129
+ )
1130
+
1131
+ def _is_barmerge_member(self, node: ASTNode, *allowed: str) -> bool:
1132
+ if not isinstance(node, MemberAccess):
1133
+ return False
1134
+ if not isinstance(node.object, Identifier) or node.object.name != "barmerge":
1135
+ return False
1136
+ return node.member in allowed
1137
+
1138
+ def _is_current_symbol_expr(self, node: ASTNode) -> bool:
1139
+ chain = _resolve_member_chain(node)
1140
+ if chain in SECURITY_CURRENT_SYMBOL_NAMES:
1141
+ return True
1142
+ # ticker.inherit(symbol, ...) and ticker.standard(symbol) are passthrough;
1143
+ # if their first argument is a current-symbol expression, allow it.
1144
+ if isinstance(node, FuncCall):
1145
+ ns, fname = _qualified_name(node.callee)
1146
+ if ns == "ticker" and fname in ("inherit", "standard"):
1147
+ if node.args and self._is_current_symbol_expr(node.args[0]):
1148
+ return True
1149
+ if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"]):
1150
+ return True
1151
+ return False
1152
+
1153
+ # -- Pine timeframe-literal validation --
1154
+
1155
+ @staticmethod
1156
+ def _validate_pine_tf_literal(tf_str: str) -> str | None:
1157
+ """Return None if ``tf_str`` is a well-formed Pine TF literal, else
1158
+ a short reason string. Accepts:
1159
+ - bare positive integer minutes: "1", "5", "60", "240", "1440"
1160
+ - seconds suffix: "1S", "30S"
1161
+ - hours / days / weeks / months suffix: "1H", "1D", "1W", "3M"
1162
+ Rejects empty, zero, negative, decimals, unknown suffixes, bare suffix.
1163
+ """
1164
+ if tf_str == "":
1165
+ return "empty timeframe literal"
1166
+ # Must be all-ASCII; split optional trailing single letter suffix.
1167
+ suffix = ""
1168
+ digits = tf_str
1169
+ if tf_str[-1].isalpha():
1170
+ suffix = tf_str[-1]
1171
+ digits = tf_str[:-1]
1172
+ if suffix not in ("S", "H", "D", "W", "M"):
1173
+ return f"unknown timeframe suffix '{suffix}'"
1174
+ if digits == "":
1175
+ # Bare suffix shorthand — Pine treats "D"/"W"/"M"/"S" as 1<suffix>.
1176
+ # Accept the same shorthand here.
1177
+ return None
1178
+ if not digits.isdigit():
1179
+ # catches "1.5", "-15", "abc", etc.
1180
+ return f"non-integer timeframe magnitude '{digits}'"
1181
+ n = int(digits)
1182
+ if n <= 0:
1183
+ return "timeframe magnitude must be positive"
1184
+ return None
1185
+
1186
+ def _check_tf_literal(self, tf_node: ASTNode | None, fn_label: str) -> None:
1187
+ """If ``tf_node`` is a string literal, validate its TF format.
1188
+ Variables / function calls / inputs are runtime-resolved and skipped.
1189
+ """
1190
+ if tf_node is None:
1191
+ return
1192
+ if not isinstance(tf_node, StringLiteral):
1193
+ return
1194
+ err = self._validate_pine_tf_literal(tf_node.value)
1195
+ if err is None:
1196
+ return
1197
+ self._err(
1198
+ tf_node,
1199
+ f"{fn_label}: invalid timeframe literal '{tf_node.value}'. "
1200
+ "Expected Pine TF format like '1', '15', '1H', '1D', '15S'.",
1201
+ hint=err,
1202
+ )
1203
+
1204
+ def _check_request_security_lower_tf_tf(self, node: FuncCall) -> None:
1205
+ """Validate the ``timeframe`` argument literal for
1206
+ ``request.security_lower_tf``. All other parameter validation lives in
1207
+ the analyzer."""
1208
+ tf_node = node.kwargs.get("timeframe")
1209
+ if tf_node is None and len(node.args) > 1:
1210
+ tf_node = node.args[1]
1211
+ self._check_tf_literal(tf_node, "request.security_lower_tf")
1212
+
1213
+
1214
+ # ---------------------------------------------------------------------------
1215
+ # Convenience entry point
1216
+ # ---------------------------------------------------------------------------
1217
+
1218
+ def check_support(ast: Program, filename: str = "<input>") -> list[Diagnostic]:
1219
+ return SupportChecker(ast, filename=filename).check()
1220
+
1221
+
1222
+ def check_support_or_raise(ast: Program, filename: str = "<input>") -> None:
1223
+ SupportChecker(ast, filename=filename).check_or_raise()