pineforge-codegen 0.6.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pineforge_codegen/__init__.py +53 -0
  2. pineforge_codegen/analyzer/__init__.py +60 -0
  3. pineforge_codegen/analyzer/base.py +1563 -0
  4. pineforge_codegen/analyzer/call_handlers.py +895 -0
  5. pineforge_codegen/analyzer/contracts.py +163 -0
  6. pineforge_codegen/analyzer/diagnostics.py +118 -0
  7. pineforge_codegen/analyzer/tables.py +204 -0
  8. pineforge_codegen/analyzer/types.py +250 -0
  9. pineforge_codegen/ast_nodes.py +293 -0
  10. pineforge_codegen/codegen/__init__.py +78 -0
  11. pineforge_codegen/codegen/base.py +1381 -0
  12. pineforge_codegen/codegen/emit_top.py +875 -0
  13. pineforge_codegen/codegen/helpers.py +163 -0
  14. pineforge_codegen/codegen/helpers_syminfo.py +134 -0
  15. pineforge_codegen/codegen/input.py +189 -0
  16. pineforge_codegen/codegen/security.py +1564 -0
  17. pineforge_codegen/codegen/ta.py +298 -0
  18. pineforge_codegen/codegen/tables.py +613 -0
  19. pineforge_codegen/codegen/types.py +573 -0
  20. pineforge_codegen/codegen/visit_call.py +1305 -0
  21. pineforge_codegen/codegen/visit_expr.py +701 -0
  22. pineforge_codegen/codegen/visit_stmt.py +729 -0
  23. pineforge_codegen/errors.py +98 -0
  24. pineforge_codegen/lexer.py +531 -0
  25. pineforge_codegen/parser.py +1198 -0
  26. pineforge_codegen/pragmas.py +117 -0
  27. pineforge_codegen/signatures.py +808 -0
  28. pineforge_codegen/support_checker.py +1111 -0
  29. pineforge_codegen/symbols.py +118 -0
  30. pineforge_codegen/tokens.py +406 -0
  31. pineforge_codegen/tv_input_choices.py +86 -0
  32. pineforge_codegen-0.6.5.dist-info/METADATA +462 -0
  33. pineforge_codegen-0.6.5.dist-info/RECORD +35 -0
  34. pineforge_codegen-0.6.5.dist-info/WHEEL +4 -0
  35. pineforge_codegen-0.6.5.dist-info/licenses/LICENSE +197 -0
@@ -0,0 +1,1111 @@
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
+ }
222
+
223
+ # Namespaces whose variable members have no batch-mode data source.
224
+ # These reads currently emit `std::string("<member>")` via the
225
+ # unknown-identifier fallthrough — which the analyzer-typed `double`
226
+ # context then rejects at C++ compile time. Catch it earlier.
227
+ UNSUPPORTED_NAMESPACE_VARS: dict[str, str] = {
228
+ "dividends": "dividends.* is not available in PineForge — fundamental dividend data is not loaded.",
229
+ "earnings": "earnings.* is not available in PineForge — fundamental earnings data is not loaded.",
230
+ "splits": "splits.* is not available in PineForge — corporate-action split data is not loaded.",
231
+ }
232
+
233
+ # request.security parameter rules.
234
+ # Codegen supports symbol/timeframe/expression plus gaps/lookahead (read in
235
+ # _eval_security_* emission and forwarded to register_security_eval).
236
+ # ignore_invalid_symbol/currency are accepted by the parser but silently
237
+ # dropped by codegen — reject loudly to surface unsupported behavior.
238
+ SECURITY_ALLOWED_PARAMS: frozenset[str] = frozenset(
239
+ {"symbol", "timeframe", "expression", "gaps", "lookahead",
240
+ # Data-adjustment constants — silently accepted and ignored by codegen;
241
+ # the underlying engine uses a fixed unadjusted data source.
242
+ "backadjustment", "settlement_as_close", "adjustment"}
243
+ )
244
+ SECURITY_PARAM_ORDER: tuple[str, ...] = (
245
+ "symbol", "timeframe", "expression",
246
+ "gaps", "lookahead", "ignore_invalid_symbol", "currency",
247
+ )
248
+ SECURITY_MAX_POSITIONAL: int = 5 # symbol, timeframe, expression, gaps, lookahead
249
+
250
+ # Per-kwarg allowed values for request.security data-adjustment params.
251
+ # Values NOT in this list cause silent wrong-result backtests: codegen
252
+ # emits a numeric constant which the engine ignores entirely, producing
253
+ # a different price series from TradingView with no warning. Reject
254
+ # loudly when the script passes anything outside the no-op set.
255
+ #
256
+ # (off / none = the engine's de-facto behavior; inherit = "follow chart
257
+ # settings" which, in batch mode with a single data source, is also a
258
+ # no-op.)
259
+ SECURITY_ADJUSTMENT_ALLOWED_VALUES: dict[str, frozenset[str]] = {
260
+ "backadjustment": frozenset({"off", "inherit"}),
261
+ "settlement_as_close": frozenset({"off", "inherit"}),
262
+ "adjustment": frozenset({"none", "inherit"}),
263
+ }
264
+ # Identifiers/expressions that resolve to "this script's symbol".
265
+ SECURITY_CURRENT_SYMBOL_NAMES: frozenset[str] = frozenset({"syminfo.tickerid", "syminfo.ticker"})
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Helpers
270
+ # ---------------------------------------------------------------------------
271
+
272
+ def _loc(node: ASTNode | None, fallback_file: str) -> SourceLocation:
273
+ if node is not None and getattr(node, "loc", None) is not None:
274
+ return node.loc
275
+ return SourceLocation(file=fallback_file, line=1, col=1, end_col=1)
276
+
277
+
278
+ def _qualified_name(callee: ASTNode) -> tuple[str | None, str | None]:
279
+ """Return (namespace, function_name) for a call's callee, or (None, None)."""
280
+ if isinstance(callee, Identifier):
281
+ return None, callee.name
282
+ if isinstance(callee, MemberAccess):
283
+ obj = callee.object
284
+ # Single-level: ns.func
285
+ if isinstance(obj, Identifier):
286
+ return obj.name, callee.member
287
+ # Multi-level: foo.bar.baz - flatten left side
288
+ if isinstance(obj, MemberAccess):
289
+ left_ns, left_name = _qualified_name(obj)
290
+ if left_ns is None and left_name is not None:
291
+ return left_name, callee.member
292
+ if left_ns is not None and left_name is not None:
293
+ return f"{left_ns}.{left_name}", callee.member
294
+ return None, None
295
+
296
+
297
+ def _resolve_member_chain(node: ASTNode) -> str | None:
298
+ """Flatten a MemberAccess/Identifier chain into a dotted name."""
299
+ if isinstance(node, Identifier):
300
+ return node.name
301
+ if isinstance(node, MemberAccess):
302
+ left = _resolve_member_chain(node.object)
303
+ if left is None:
304
+ return None
305
+ return f"{left}.{node.member}"
306
+ return None
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Checker
311
+ # ---------------------------------------------------------------------------
312
+
313
+ class SupportChecker:
314
+ """AST walker that produces Diagnostic objects for unsupported features."""
315
+
316
+ # syminfo fields that emit na<T>() due to missing data; warn when used in
317
+ # conditional context (if-condition or ternary condition) because the
318
+ # condition will always evaluate to false/na.
319
+ _SYMINFO_SILENT_GAP_FIELDS: frozenset[str] = frozenset({
320
+ "sector", "industry", "isin",
321
+ "expiration_date", "current_contract", "mincontract",
322
+ })
323
+
324
+ def __init__(self, ast: Program, filename: str = "<input>") -> None:
325
+ self._ast = ast
326
+ self._filename = filename
327
+ self._diagnostics: list[Diagnostic] = []
328
+ # Names defined locally (UDTs, enums, user functions, methods) so we
329
+ # don't flag user-defined identifiers as unknown built-ins.
330
+ self._user_types: set[str] = set()
331
+ self._user_enums: set[str] = set()
332
+ self._user_funcs: set[str] = set()
333
+ self._user_methods: set[str] = set()
334
+ # Track whether we are inside an if/ternary condition expression.
335
+ self._in_conditional_depth: int = 0
336
+
337
+ # -- Public API --
338
+
339
+ def check(self) -> list[Diagnostic]:
340
+ self._collect_user_definitions(self._ast)
341
+ for stmt in self._ast.body:
342
+ self._visit(stmt)
343
+ return self._diagnostics
344
+
345
+ def check_or_raise(self) -> None:
346
+ diags = self.check()
347
+ errors = [d for d in diags if d.level == Level.ERROR]
348
+ if errors:
349
+ raise CompileError(diags)
350
+
351
+ # -- Setup --
352
+
353
+ def _collect_user_definitions(self, ast: Program) -> None:
354
+ for stmt in ast.body:
355
+ if isinstance(stmt, TypeDecl):
356
+ self._user_types.add(stmt.name)
357
+ elif isinstance(stmt, EnumDecl):
358
+ self._user_enums.add(stmt.name)
359
+ elif isinstance(stmt, FuncDef):
360
+ self._user_funcs.add(stmt.name)
361
+ elif isinstance(stmt, MethodDef):
362
+ self._user_methods.add(stmt.name)
363
+
364
+ # -- Diagnostic emission --
365
+
366
+ def _err(self, node: ASTNode | None, message: str, hint: str | None = None) -> None:
367
+ self._diagnostics.append(Diagnostic(
368
+ level=Level.ERROR,
369
+ phase=Phase.ANALYZER,
370
+ location=_loc(node, self._filename),
371
+ message=message,
372
+ hint=hint,
373
+ ))
374
+
375
+ def _warn(self, node: ASTNode | None, message: str, hint: str | None = None) -> None:
376
+ self._diagnostics.append(Diagnostic(
377
+ level=Level.WARNING,
378
+ phase=Phase.ANALYZER,
379
+ location=_loc(node, self._filename),
380
+ message=message,
381
+ hint=hint,
382
+ ))
383
+
384
+ def _reject_if_in(
385
+ self,
386
+ table: dict,
387
+ key,
388
+ node: ASTNode,
389
+ msg_fmt: Callable[[object, str], str],
390
+ hint: str | None = None,
391
+ ) -> bool:
392
+ """Look ``key`` up in ``table``; if present, emit an error built from
393
+ ``msg_fmt(key, table[key])``, visit children, and return True. Otherwise
394
+ return False so the caller can fall through to later checks.
395
+
396
+ Consolidates the near-identical lookup+emit blocks that dispatch the
397
+ UNSUPPORTED_* tables in _visit_FuncCall / _visit_MemberAccess.
398
+ """
399
+ if key not in table:
400
+ return False
401
+ self._err(node, msg_fmt(key, table[key]), hint=hint)
402
+ self._visit_children(node)
403
+ return True
404
+
405
+ # -- Visitor dispatch --
406
+
407
+ def _visit(self, node: ASTNode | None) -> None:
408
+ if node is None:
409
+ return
410
+ method = getattr(self, f"_visit_{type(node).__name__}", None)
411
+ if method is not None:
412
+ method(node)
413
+ return
414
+ self._visit_children(node)
415
+
416
+ def _visit_children(self, node: ASTNode) -> None:
417
+ for value in vars(node).values():
418
+ if isinstance(value, ASTNode):
419
+ self._visit(value)
420
+ elif isinstance(value, list):
421
+ for item in value:
422
+ if isinstance(item, ASTNode):
423
+ self._visit(item)
424
+ elif isinstance(value, dict):
425
+ for item in value.values():
426
+ if isinstance(item, ASTNode):
427
+ self._visit(item)
428
+
429
+ # -- Specific visitors --
430
+
431
+ @staticmethod
432
+ def _param_is_provided(node: FuncCall, namespace: str | None, func_name: str, param_name: str) -> bool:
433
+ if param_name in node.kwargs:
434
+ return True
435
+ param_names = sigs.get_param_names(namespace, func_name) or []
436
+ try:
437
+ idx = param_names.index(param_name)
438
+ except ValueError:
439
+ return False
440
+ return idx < len(node.args)
441
+
442
+ @staticmethod
443
+ def _is_color_constant_or_builder(expr: ASTNode) -> bool:
444
+ """True when ``expr`` is a Pine color literal/builder safe to feed
445
+ into the codegen's packed-int input route.
446
+
447
+ Accepts:
448
+ * ``color.<const>`` (e.g. ``color.red``)
449
+ * ``color.new(...)`` / ``color.rgb(...)`` builder calls
450
+ * ``ColorLiteral`` (#rrggbb syntax)
451
+ """
452
+ if isinstance(expr, ColorLiteral):
453
+ return True
454
+ if isinstance(expr, MemberAccess) and isinstance(expr.object, Identifier):
455
+ if expr.object.name == "color":
456
+ return True
457
+ if isinstance(expr, FuncCall):
458
+ ns, name = _qualified_name(expr.callee)
459
+ if ns == "color" and name in ("new", "rgb"):
460
+ return True
461
+ return False
462
+
463
+ def _check_input_color_defval(self, node: FuncCall) -> None:
464
+ """input.color: reject defvals that aren't a color constant/builder."""
465
+ defval: ASTNode | None = None
466
+ if node.args:
467
+ defval = node.args[0]
468
+ elif "defval" in node.kwargs:
469
+ defval = node.kwargs["defval"]
470
+ if defval is None:
471
+ self._err(
472
+ node,
473
+ "input.color(...) requires a color defval "
474
+ "(e.g. color.red or color.new(color.red, 50)).",
475
+ )
476
+ return
477
+ if not self._is_color_constant_or_builder(defval):
478
+ self._err(
479
+ node,
480
+ "input.color(...) defval must be a color constant "
481
+ "(color.red, ...), a color literal (#rrggbb), or a "
482
+ "color.new(...) / color.rgb(...) builder. Arbitrary "
483
+ "expressions are not supported: the engine has no color "
484
+ "helper, so codegen routes input.color through the int "
485
+ "getter — any non-color defval would silently store a "
486
+ "numeric value with no color encoding.",
487
+ hint="Replace the defval with color.<name> or color.new(...).",
488
+ )
489
+
490
+ def _check_input_source_defval(self, node: FuncCall) -> None:
491
+ """input.source: reject defvals that aren't a native chart series.
492
+
493
+ PineForge restricts input.source strictly to native OHLCV series
494
+ (open/high/low/close/volume/hl2/hlc3/ohlc4/hlcc4) — the engine's
495
+ runtime override (get_input_source) can only resolve to those base
496
+ series. User series, computed expressions, and indicator outputs
497
+ have no resolvable backing series; without this guard codegen would
498
+ bind to the close fallback, silently using the wrong series."""
499
+ defval: ASTNode | None = None
500
+ if node.args:
501
+ defval = node.args[0]
502
+ elif "defval" in node.kwargs:
503
+ defval = node.kwargs["defval"]
504
+ if defval is None:
505
+ self._err(
506
+ node,
507
+ "input.source(...) requires a native series defval "
508
+ "(close, open, high, low, hl2, hlc3, ohlc4, ...).",
509
+ )
510
+ return
511
+ if not (isinstance(defval, Identifier)
512
+ and defval.name in INPUT_SOURCE_SERIES_IDS):
513
+ self._err(
514
+ node,
515
+ "input.source(...) defval must be a native chart series "
516
+ "(open, high, low, close, volume, hl2, hlc3, ohlc4, hlcc4). "
517
+ "User series, computed expressions, and indicator outputs "
518
+ "are not supported: input.source is restricted to native "
519
+ "OHLCV series.",
520
+ hint="Use one of: close, open, high, low, hl2, hlc3, ohlc4.",
521
+ )
522
+
523
+ def _visit_StrategyDecl(self, node: StrategyDecl) -> None:
524
+ kind = (node.annotations or {}).get("decl_kind", "strategy")
525
+ if kind != "strategy":
526
+ self._err(
527
+ node,
528
+ f"{kind}() declarations are not supported; PineForge runs strategies only.",
529
+ hint="Replace the declaration with strategy(...) and add explicit entry/exit calls.",
530
+ )
531
+ self._visit_children(node)
532
+
533
+ def _visit_VarDecl(self, node: VarDecl) -> None:
534
+ if node.is_varip:
535
+ self._err(
536
+ node,
537
+ "varip is not supported in PineForge batch backtests — there "
538
+ "are no intrabar ticks. Codegen would silently demote varip "
539
+ "to var, producing incorrect state accumulation for any "
540
+ "script that relies on tick-level updates.",
541
+ hint=(
542
+ "Replace 'varip' with 'var' if the strategy logic does "
543
+ "not depend on tick-level updates, or run the strategy "
544
+ "in hosted TradingView Studio."
545
+ ),
546
+ )
547
+ self._visit_children(node)
548
+
549
+ def _visit_TupleAssign(self, node: TupleAssign) -> None:
550
+ if isinstance(node.value, FuncCall):
551
+ ns, name = _qualified_name(node.value.callee)
552
+ if ns == "ta" and name == "stoch":
553
+ self._err(
554
+ node.value,
555
+ "ta.stoch(...) returns a single series in PineForge; tuple destructuring is not supported.",
556
+ hint="Use k = ta.stoch(...) and compute smoothing separately if needed.",
557
+ )
558
+ self._visit_children(node)
559
+
560
+ def _visit_FuncCall(self, node: FuncCall) -> None:
561
+ ns, name = _qualified_name(node.callee)
562
+
563
+ if ns is None and name is None:
564
+ self._visit_children(node)
565
+ return
566
+
567
+ full = f"{ns}.{name}" if ns else name
568
+
569
+ # Hard rejects by full name.
570
+ if full in HARD_REJECT_FUNC:
571
+ self._err(node, f"{full}(...) is not supported.", hint=HARD_REJECT_FUNC[full])
572
+ self._visit_children(node)
573
+ return
574
+
575
+ # Hard rejects by namespace (e.g. ticker.*).
576
+ if ns is not None and ns in HARD_REJECT_NAMESPACE:
577
+ self._err(
578
+ node,
579
+ f"{full}(...) is not supported.",
580
+ hint=HARD_REJECT_NAMESPACE[ns],
581
+ )
582
+ self._visit_children(node)
583
+ return
584
+
585
+ # Not-yet-implemented. Check qualified name (e.g. "timeframe.from_seconds")
586
+ # first, then bare name (e.g. "max_bars_back") for back-compat entries.
587
+ if full in NOT_YET_FUNC:
588
+ self._err(node, f"{full}(...) is not implemented yet.", hint=NOT_YET_FUNC[full])
589
+ self._visit_children(node)
590
+ return
591
+ if ns is not None and name in NOT_YET_FUNC:
592
+ self._err(node, f"{name}(...) is not implemented yet.", hint=NOT_YET_FUNC[name])
593
+ self._visit_children(node)
594
+ return
595
+
596
+ # Bare-function rejections (e.g. `color(arg)` cast). Codegen would
597
+ # otherwise fall through to the generic emit at visit_call.py:912 and
598
+ # produce an undeclared C++ symbol.
599
+ if ns is None and self._reject_if_in(
600
+ UNSUPPORTED_BARE_FUNCS,
601
+ name,
602
+ node,
603
+ lambda k, v: f"{k}(...): {v}",
604
+ hint="See https://www.tradingview.com/pine-script-docs/concepts/colors/ for the supported color builders.",
605
+ ):
606
+ return
607
+
608
+ # Whole-namespace rejections (e.g. footprint.*, volume_row.*).
609
+ if ns is not None and self._reject_if_in(
610
+ UNSUPPORTED_NAMESPACES,
611
+ ns,
612
+ node,
613
+ lambda k, v: f"{full}: {v}",
614
+ hint="Remove the call or replace it with native OHLCV-based logic.",
615
+ ):
616
+ return
617
+
618
+ # Bare barssince() — codegen emits 0 (broken).
619
+ if ns is None and name == "library":
620
+ self._err(
621
+ node,
622
+ "library() declarations are not supported; PineForge runs strategies only.",
623
+ hint="Use strategy(...) and inline or pre-expand library code before uploading.",
624
+ )
625
+ self._visit_children(node)
626
+ return
627
+
628
+ # Bare barssince() — codegen emits 0 (broken).
629
+ if ns is None and name == "barssince":
630
+ self._err(
631
+ node,
632
+ "Bare barssince(...) is broken in PineForge codegen.",
633
+ hint="Use ta.barssince(...) instead.",
634
+ )
635
+ self._visit_children(node)
636
+ return
637
+
638
+ # ta.sum — not a real Pine identifier.
639
+ if ns == "ta" and name == "sum":
640
+ self._err(
641
+ node,
642
+ "ta.sum is not a PineScript v6 function.",
643
+ hint="Use math.sum(...) instead.",
644
+ )
645
+ self._visit_children(node)
646
+ return
647
+
648
+ if ns == "ta" and name in TA_PROPERTY_VARIABLES:
649
+ self._err(
650
+ node,
651
+ f"ta.{name} is a PineScript v6 variable, not a function.",
652
+ hint=f"Use ta.{name} without parentheses.",
653
+ )
654
+ self._visit_children(node)
655
+ return
656
+
657
+ if ns == "strategy.closedtrades":
658
+ if name not in CLOSED_TRADE_ACCESSOR_METHODS:
659
+ self._err(node, f"{full}(...) is not implemented in PineForge runtime.")
660
+ self._visit_children(node)
661
+ return
662
+ if ns == "strategy.opentrades":
663
+ if name not in OPEN_TRADE_ACCESSOR_METHODS:
664
+ hint = None
665
+ if name in CLOSED_TRADE_ACCESSOR_METHODS:
666
+ hint = (
667
+ f"strategy.opentrades has no '{name}' accessor in Pine v6 "
668
+ f"(it only exists on strategy.closedtrades)."
669
+ )
670
+ self._err(node, f"{full}(...) is not implemented in PineForge runtime.", hint=hint)
671
+ self._visit_children(node)
672
+ return
673
+
674
+ if ns == "strategy" and name not in sigs.STRATEGY_FUNCTIONS:
675
+ self._err(node, f"strategy.{name}(...) is not implemented in PineForge runtime.")
676
+ self._visit_children(node)
677
+ return
678
+
679
+ if ns == "strategy" and name == "exit":
680
+ has_price_param = any(
681
+ self._param_is_provided(node, "strategy", "exit", param)
682
+ for param in STRATEGY_EXIT_PRICE_PARAMS
683
+ )
684
+ if not has_price_param:
685
+ self._err(
686
+ node,
687
+ "strategy.exit(...) requires at least one price or trailing parameter in PineForge.",
688
+ hint="Use strategy.close(...) for market exits.",
689
+ )
690
+
691
+ # request.security strictness.
692
+ if full == "request.security":
693
+ self._check_request_security(node)
694
+ self._visit_children(node)
695
+ return
696
+ # request.security_lower_tf — analyzer/codegen handle parameter validation
697
+ # and element-type rejection (UDT/color/string). Still validate the
698
+ # timeframe literal here so codegen catches malformed TF strings early.
699
+ if full == "request.security_lower_tf":
700
+ self._check_request_security_lower_tf_tf(node)
701
+ self._visit_children(node)
702
+ return
703
+ if ns == "request":
704
+ self._err(
705
+ node,
706
+ f"{full}(...) is not supported. Only request.security(...) and request.security_lower_tf(...) are available in PineForge.",
707
+ hint="External request feeds and other request.* variants are intentionally unavailable.",
708
+ )
709
+ self._visit_children(node)
710
+ return
711
+
712
+ # strategy.risk.* is partially supported by codegen/runtime for common
713
+ # risk limits. Warn because TradingView risk semantics are broad and
714
+ # not every edge case is modeled exactly.
715
+ if ns == "strategy.risk":
716
+ self._warn(
717
+ node,
718
+ f"strategy.risk.{name}(...) has partial PineForge runtime support.",
719
+ hint="Verify risk behavior against PineForge results; unsupported risk edge cases may diverge from TradingView.",
720
+ )
721
+ self._visit_children(node)
722
+ return
723
+
724
+ if ns == "strategy" and name in STRATEGY_UNSUPPORTED_PARAMS:
725
+ for param_name in sorted(STRATEGY_UNSUPPORTED_PARAMS[name]):
726
+ if self._param_is_provided(node, "strategy", name, param_name):
727
+ warn_node = node.kwargs.get(param_name, node)
728
+ self._warn(
729
+ warn_node,
730
+ f"strategy.{name} parameter '{param_name}' is not supported by PineForge and is ignored.",
731
+ )
732
+
733
+ # Unknown ta.* / math.* / str.* / input.* - codegen emits silent stubs.
734
+ if ns == "ta" and name not in SUPPORTED_TA:
735
+ self._err(node, f"ta.{name}(...) is not implemented in PineForge runtime.")
736
+ self._visit_children(node)
737
+ return
738
+ if ns == "math" and name not in SUPPORTED_MATH:
739
+ self._err(node, f"math.{name}(...) is not implemented in PineForge runtime.")
740
+ self._visit_children(node)
741
+ return
742
+ if ns == "str" and name not in SUPPORTED_STR:
743
+ self._err(node, f"str.{name}(...) is not implemented in PineForge runtime.")
744
+ self._visit_children(node)
745
+ return
746
+ if ns == "input" and name not in SUPPORTED_INPUT:
747
+ self._err(node, f"input.{name}(...) is not implemented in PineForge runtime.")
748
+ self._visit_children(node)
749
+ return
750
+ if ns == "input" and name == "color":
751
+ # The engine has no get_input_color helper; codegen routes
752
+ # input.color through get_input_int with the defval emitted as
753
+ # a packed RGBA int. That is only meaningful when the defval
754
+ # itself is a color literal or builder. An arbitrary int/identifier
755
+ # would silently bind a numeric value with no color encoding,
756
+ # producing wrong-colored UI (or worse, ambiguous state if the
757
+ # value is ever passed back to a color-aware sink).
758
+ self._check_input_color_defval(node)
759
+ if ns == "input" and name == "source":
760
+ # input.source is restricted to native OHLCV series — the engine
761
+ # can only resolve a runtime override to a native base series.
762
+ self._check_input_source_defval(node)
763
+ if ns == "timeframe" and name not in SUPPORTED_TIMEFRAME_FUNC:
764
+ self._err(node, f"timeframe.{name}(...) is not implemented in PineForge runtime.")
765
+ self._visit_children(node)
766
+ return
767
+ if ns == "color" and name not in SUPPORTED_COLOR_FUNC:
768
+ self._err(node, f"color.{name}(...) is not implemented in PineForge runtime.")
769
+ self._visit_children(node)
770
+ return
771
+ if ns == "runtime" and name not in SUPPORTED_RUNTIME_FUNC:
772
+ self._err(node, f"runtime.{name}(...) is not implemented in PineForge runtime.")
773
+ self._visit_children(node)
774
+ return
775
+ if ns == "log" and name not in SUPPORTED_LOG:
776
+ self._err(node, f"log.{name}(...) is not implemented in PineForge runtime.")
777
+ self._visit_children(node)
778
+ return
779
+ if ns == "array" and name not in SUPPORTED_ARRAY:
780
+ self._err(node, f"array.{name}(...) is not implemented in PineForge runtime.")
781
+ self._visit_children(node)
782
+ return
783
+ if ns == "map" and name not in SUPPORTED_MAP:
784
+ self._err(node, f"map.{name}(...) is not implemented in PineForge runtime.")
785
+ self._visit_children(node)
786
+ return
787
+ if ns == "map" and name == "new":
788
+ targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or []
789
+ targs = [str(t).replace(" ", "") for t in targs]
790
+ if targs and targs[0] != "string":
791
+ self._err(node, "map keys must be string in PineForge's supported map subset.")
792
+ if len(targs) > 1 and targs[1] not in {"float", "int", "bool", "string"}:
793
+ self._err(node, "map values must be primitive in PineForge's supported map subset.")
794
+ if ns == "matrix" and name == "new":
795
+ targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or []
796
+ targs = [str(t).replace(" ", "") for t in targs]
797
+ if targs:
798
+ t = targs[0]
799
+ if "<" in t:
800
+ self._err(node, f"matrix<{t}>: nested collection element types not supported in v1.")
801
+ self._visit_children(node)
802
+ return
803
+ allowed_prim = {"float", "int", "bool", "string", "color"}
804
+ if t not in allowed_prim and t not in self._user_types:
805
+ self._err(node, f"matrix<{t}> element type not supported. Allowed: float, int, bool, string, color, or a declared UDT.")
806
+ self._visit_children(node)
807
+ return
808
+ if ns == "matrix" and name not in SUPPORTED_MATRIX:
809
+ self._err(node, f"matrix.{name}(...) is not implemented in PineForge runtime.")
810
+ self._visit_children(node)
811
+ return
812
+
813
+ # Drawing / charting / alert namespaces — codegen drops silently. Warn,
814
+ # don't error: many strategies include these for the TradingView UI.
815
+ if ns is None and name in SKIP_FUNC_NAMES:
816
+ self._warn(
817
+ node,
818
+ f"{name}(...) has no effect in PineForge backtests (visual only).",
819
+ )
820
+ if ns is not None and ns in SKIP_NAMESPACES:
821
+ self._warn(
822
+ node,
823
+ f"{full}(...) has no effect in PineForge backtests (visual only).",
824
+ )
825
+
826
+ self._visit_children(node)
827
+
828
+ def _visit_Identifier(self, node: Identifier) -> None:
829
+ if node.name in DIVERGENT_VARS:
830
+ self._warn(
831
+ node,
832
+ f"{node.name} diverges from TradingView semantics in PineForge.",
833
+ hint=DIVERGENT_VARS[node.name],
834
+ )
835
+
836
+ def _visit_IfStmt(self, node: IfStmt) -> None:
837
+ """Visit if-statement; mark the condition as conditional context."""
838
+ self._in_conditional_depth += 1
839
+ self._visit(node.condition)
840
+ self._in_conditional_depth -= 1
841
+ for stmt in node.body:
842
+ self._visit(stmt)
843
+ for stmt in node.else_body:
844
+ self._visit(stmt)
845
+
846
+ def _visit_Ternary(self, node: Ternary) -> None:
847
+ """Visit ternary; mark the condition expression as conditional context."""
848
+ self._in_conditional_depth += 1
849
+ self._visit(node.condition)
850
+ self._in_conditional_depth -= 1
851
+ self._visit(node.true_val)
852
+ self._visit(node.false_val)
853
+
854
+ def _visit_MemberAccess(self, node: MemberAccess) -> None:
855
+ chain = _resolve_member_chain(node)
856
+ if chain is not None and chain in DIVERGENT_VARS:
857
+ self._warn(
858
+ node,
859
+ f"{chain} diverges from TradingView semantics in PineForge.",
860
+ hint=DIVERGENT_VARS[chain],
861
+ )
862
+ if chain is not None and chain in BARSTATE_APPROX_VARS:
863
+ self._warn(
864
+ node,
865
+ f"{chain} is approximated in PineForge.",
866
+ hint=BARSTATE_APPROX_VARS[chain],
867
+ )
868
+ # Whole-namespace rejections via member access (e.g. footprint.SomeType).
869
+ if isinstance(node.object, Identifier) and self._reject_if_in(
870
+ UNSUPPORTED_NAMESPACES,
871
+ node.object.name,
872
+ node,
873
+ lambda k, v: f"{k}.{node.member}: {v}",
874
+ ):
875
+ return
876
+ # Specific unsupported (namespace, member) pairs (e.g. chart.left_visible_bar_time).
877
+ if isinstance(node.object, Identifier) and self._reject_if_in(
878
+ UNSUPPORTED_MEMBERS,
879
+ (node.object.name, node.member),
880
+ node,
881
+ lambda k, v: f"{k[0]}.{k[1]}: {v}",
882
+ ):
883
+ return
884
+ # Namespace-wide variable rejections (e.g. dividends.*, earnings.*).
885
+ if isinstance(node.object, Identifier) and self._reject_if_in(
886
+ UNSUPPORTED_NAMESPACE_VARS,
887
+ node.object.name,
888
+ node,
889
+ lambda k, v: f"{k}.{node.member}: {v}",
890
+ ):
891
+ return
892
+ if isinstance(node.object, Identifier) and node.object.name == "syminfo":
893
+ if node.member not in SUPPORTED_SYMINFO:
894
+ self._err(node, f"syminfo.{node.member} is not implemented in PineForge runtime.")
895
+ elif (
896
+ self._in_conditional_depth > 0
897
+ and node.member in self._SYMINFO_SILENT_GAP_FIELDS
898
+ ):
899
+ self._warn(
900
+ node,
901
+ f"syminfo.{node.member} returns na in current PineForge; "
902
+ "condition will always be false. "
903
+ "Will be backfilled by pineforge-data product.",
904
+ )
905
+ self._visit_children(node)
906
+
907
+ # -- request.security parameter rules --
908
+
909
+ def _check_request_security(self, node: FuncCall) -> None:
910
+ """Validate request.security: symbol/timeframe/expression/gaps/lookahead.
911
+
912
+ Rejects ignore_invalid_symbol/currency (codegen drops silently).
913
+ Symbol must reference current chart symbol.
914
+ gaps/lookahead must be barmerge.{gaps,lookahead}_{on,off} member access
915
+ (codegen only recognizes that shape; anything else silently dropped).
916
+ """
917
+ allowed_hint = (
918
+ "Only symbol, timeframe, expression, gaps, and lookahead are accepted."
919
+ )
920
+
921
+ # Disallowed kwargs.
922
+ for kw_name in node.kwargs:
923
+ if kw_name not in SECURITY_ALLOWED_PARAMS:
924
+ self._err(
925
+ node,
926
+ f"request.security parameter '{kw_name}' is not allowed in PineForge.",
927
+ hint=allowed_hint,
928
+ )
929
+
930
+ # Disallowed positional args (6th onward = ignore_invalid_symbol/currency).
931
+ if len(node.args) > SECURITY_MAX_POSITIONAL:
932
+ for extra in node.args[SECURITY_MAX_POSITIONAL:]:
933
+ self._err(
934
+ extra,
935
+ "Extra positional arguments to request.security are not allowed in PineForge.",
936
+ hint=allowed_hint,
937
+ )
938
+
939
+ # Symbol-must-be-current check.
940
+ symbol_node = None
941
+ if node.args:
942
+ symbol_node = node.args[0]
943
+ elif "symbol" in node.kwargs:
944
+ symbol_node = node.kwargs["symbol"]
945
+
946
+ if symbol_node is not None and not self._is_current_symbol_expr(symbol_node):
947
+ self._err(
948
+ symbol_node,
949
+ "request.security symbol must reference the current chart symbol.",
950
+ hint="Use syminfo.tickerid or syminfo.ticker; PineForge backtests do not load alternate symbols.",
951
+ )
952
+
953
+ # timeframe literal-format check (positional [1] or kwarg).
954
+ tf_node = node.kwargs.get("timeframe")
955
+ if tf_node is None and len(node.args) > 1:
956
+ tf_node = node.args[1]
957
+ self._check_tf_literal(tf_node, "request.security")
958
+
959
+ # gaps / lookahead value-shape check (positional or kwarg).
960
+ gaps_node = node.kwargs.get("gaps")
961
+ if gaps_node is None and len(node.args) > 3:
962
+ gaps_node = node.args[3]
963
+ if gaps_node is not None and not self._is_barmerge_member(gaps_node, "gaps_on", "gaps_off"):
964
+ self._err(
965
+ gaps_node,
966
+ "request.security gaps must be barmerge.gaps_on or barmerge.gaps_off.",
967
+ hint="Codegen only recognizes the barmerge.gaps_* literal; other values are silently treated as gaps_off.",
968
+ )
969
+
970
+ lookahead_node = node.kwargs.get("lookahead")
971
+ if lookahead_node is None and len(node.args) > 4:
972
+ lookahead_node = node.args[4]
973
+ if lookahead_node is not None and not self._is_barmerge_member(
974
+ lookahead_node, "lookahead_on", "lookahead_off"
975
+ ):
976
+ self._err(
977
+ lookahead_node,
978
+ "request.security lookahead must be barmerge.lookahead_on or barmerge.lookahead_off.",
979
+ hint="Codegen only recognizes the barmerge.lookahead_* literal; other values are silently treated as lookahead_off.",
980
+ )
981
+ if self._is_barmerge_member(lookahead_node, "lookahead_on"):
982
+ self._err(
983
+ lookahead_node,
984
+ "request.security lookahead_on is not supported in PineForge paid parity mode.",
985
+ hint="Use barmerge.lookahead_off. lookahead_on can expose future/partial HTF values and is highly data-sensitive.",
986
+ )
987
+
988
+ # Data-adjustment kwargs: codegen emits a numeric constant but the
989
+ # engine ignores it entirely. Only the no-op values are honored
990
+ # implicitly; reject anything else loudly to surface the silent-
991
+ # wrong-result bug. See SECURITY_ADJUSTMENT_ALLOWED_VALUES.
992
+ self._check_security_adjustment_kwargs(node)
993
+
994
+ def _check_security_adjustment_kwargs(self, node: FuncCall) -> None:
995
+ """Reject request.security adjustment kwargs that the engine drops."""
996
+ for kw_name, allowed in SECURITY_ADJUSTMENT_ALLOWED_VALUES.items():
997
+ if kw_name not in node.kwargs:
998
+ continue
999
+ val = node.kwargs[kw_name]
1000
+ # Expected shape: ``<kw_name>.<member>`` MemberAccess.
1001
+ if isinstance(val, MemberAccess) and isinstance(val.object, Identifier):
1002
+ if val.object.name != kw_name or val.member not in allowed:
1003
+ self._err(
1004
+ val,
1005
+ f"request.security: {kw_name}={val.object.name}.{val.member} "
1006
+ f"is not supported. The engine ignores this kwarg, which "
1007
+ f"would silently produce different prices from TradingView.",
1008
+ hint=f"Allowed values: {sorted(allowed)}.",
1009
+ )
1010
+ else:
1011
+ self._err(
1012
+ val,
1013
+ f"request.security: {kw_name}=... must be a constant member "
1014
+ f"access of the form {kw_name}.<value> "
1015
+ f"(got a non-constant expression).",
1016
+ hint=f"Allowed values: {sorted(allowed)}.",
1017
+ )
1018
+
1019
+ def _is_barmerge_member(self, node: ASTNode, *allowed: str) -> bool:
1020
+ if not isinstance(node, MemberAccess):
1021
+ return False
1022
+ if not isinstance(node.object, Identifier) or node.object.name != "barmerge":
1023
+ return False
1024
+ return node.member in allowed
1025
+
1026
+ def _is_current_symbol_expr(self, node: ASTNode) -> bool:
1027
+ chain = _resolve_member_chain(node)
1028
+ if chain in SECURITY_CURRENT_SYMBOL_NAMES:
1029
+ return True
1030
+ # ticker.inherit(symbol, ...) and ticker.standard(symbol) are passthrough;
1031
+ # if their first argument is a current-symbol expression, allow it.
1032
+ if isinstance(node, FuncCall):
1033
+ ns, fname = _qualified_name(node.callee)
1034
+ if ns == "ticker" and fname in ("inherit", "standard"):
1035
+ if node.args and self._is_current_symbol_expr(node.args[0]):
1036
+ return True
1037
+ if "symbol" in node.kwargs and self._is_current_symbol_expr(node.kwargs["symbol"]):
1038
+ return True
1039
+ return False
1040
+
1041
+ # -- Pine timeframe-literal validation --
1042
+
1043
+ @staticmethod
1044
+ def _validate_pine_tf_literal(tf_str: str) -> str | None:
1045
+ """Return None if ``tf_str`` is a well-formed Pine TF literal, else
1046
+ a short reason string. Accepts:
1047
+ - bare positive integer minutes: "1", "5", "60", "240", "1440"
1048
+ - seconds suffix: "1S", "30S"
1049
+ - hours / days / weeks / months suffix: "1H", "1D", "1W", "3M"
1050
+ Rejects empty, zero, negative, decimals, unknown suffixes, bare suffix.
1051
+ """
1052
+ if tf_str == "":
1053
+ return "empty timeframe literal"
1054
+ # Must be all-ASCII; split optional trailing single letter suffix.
1055
+ suffix = ""
1056
+ digits = tf_str
1057
+ if tf_str[-1].isalpha():
1058
+ suffix = tf_str[-1]
1059
+ digits = tf_str[:-1]
1060
+ if suffix not in ("S", "H", "D", "W", "M"):
1061
+ return f"unknown timeframe suffix '{suffix}'"
1062
+ if digits == "":
1063
+ # Bare suffix shorthand — Pine treats "D"/"W"/"M"/"S" as 1<suffix>.
1064
+ # Accept the same shorthand here.
1065
+ return None
1066
+ if not digits.isdigit():
1067
+ # catches "1.5", "-15", "abc", etc.
1068
+ return f"non-integer timeframe magnitude '{digits}'"
1069
+ n = int(digits)
1070
+ if n <= 0:
1071
+ return "timeframe magnitude must be positive"
1072
+ return None
1073
+
1074
+ def _check_tf_literal(self, tf_node: ASTNode | None, fn_label: str) -> None:
1075
+ """If ``tf_node`` is a string literal, validate its TF format.
1076
+ Variables / function calls / inputs are runtime-resolved and skipped.
1077
+ """
1078
+ if tf_node is None:
1079
+ return
1080
+ if not isinstance(tf_node, StringLiteral):
1081
+ return
1082
+ err = self._validate_pine_tf_literal(tf_node.value)
1083
+ if err is None:
1084
+ return
1085
+ self._err(
1086
+ tf_node,
1087
+ f"{fn_label}: invalid timeframe literal '{tf_node.value}'. "
1088
+ "Expected Pine TF format like '1', '15', '1H', '1D', '15S'.",
1089
+ hint=err,
1090
+ )
1091
+
1092
+ def _check_request_security_lower_tf_tf(self, node: FuncCall) -> None:
1093
+ """Validate the ``timeframe`` argument literal for
1094
+ ``request.security_lower_tf``. All other parameter validation lives in
1095
+ the analyzer."""
1096
+ tf_node = node.kwargs.get("timeframe")
1097
+ if tf_node is None and len(node.args) > 1:
1098
+ tf_node = node.args[1]
1099
+ self._check_tf_literal(tf_node, "request.security_lower_tf")
1100
+
1101
+
1102
+ # ---------------------------------------------------------------------------
1103
+ # Convenience entry point
1104
+ # ---------------------------------------------------------------------------
1105
+
1106
+ def check_support(ast: Program, filename: str = "<input>") -> list[Diagnostic]:
1107
+ return SupportChecker(ast, filename=filename).check()
1108
+
1109
+
1110
+ def check_support_or_raise(ast: Program, filename: str = "<input>") -> None:
1111
+ SupportChecker(ast, filename=filename).check_or_raise()