@pineforge/codegen-pyodide 0.10.4 → 1.0.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.
- package/README.md +16 -16
- package/glue.py +24 -16
- package/package.json +1 -1
- package/pineforge_codegen/__init__.py +125 -34
- package/pineforge_codegen/analyzer/__init__.py +2 -0
- package/pineforge_codegen/analyzer/base.py +754 -76
- package/pineforge_codegen/analyzer/call_handlers.py +260 -40
- package/pineforge_codegen/analyzer/contracts.py +37 -0
- package/pineforge_codegen/analyzer/diagnostics.py +30 -4
- package/pineforge_codegen/analyzer/tables.py +49 -8
- package/pineforge_codegen/analyzer/types.py +33 -1
- package/pineforge_codegen/ast_nodes.py +32 -1
- package/pineforge_codegen/block_locals.py +185 -0
- package/pineforge_codegen/builtin_keywords.py +42 -0
- package/pineforge_codegen/codegen/base.py +896 -156
- package/pineforge_codegen/codegen/constant_fold.py +131 -0
- package/pineforge_codegen/codegen/drawing.py +221 -79
- package/pineforge_codegen/codegen/emit_top.py +946 -213
- package/pineforge_codegen/codegen/helpers.py +435 -14
- package/pineforge_codegen/codegen/host_members.py +162 -0
- package/pineforge_codegen/codegen/input.py +252 -85
- package/pineforge_codegen/codegen/security.py +4372 -377
- package/pineforge_codegen/codegen/session_market.py +71 -0
- package/pineforge_codegen/codegen/ta.py +1188 -100
- package/pineforge_codegen/codegen/tables.py +193 -71
- package/pineforge_codegen/codegen/tv_number_format.py +270 -0
- package/pineforge_codegen/codegen/types.py +1882 -78
- package/pineforge_codegen/codegen/visit_call.py +920 -131
- package/pineforge_codegen/codegen/visit_expr.py +738 -57
- package/pineforge_codegen/codegen/visit_stmt.py +595 -49
- package/pineforge_codegen/external_requests.py +877 -0
- package/pineforge_codegen/lexer.py +104 -22
- package/pineforge_codegen/library_inline.py +1304 -0
- package/pineforge_codegen/library_modules.py +126 -0
- package/pineforge_codegen/library_v5.py +683 -0
- package/pineforge_codegen/limits.py +138 -0
- package/pineforge_codegen/method_binding.py +33 -0
- package/pineforge_codegen/parser.py +384 -68
- package/pineforge_codegen/pine_libraries.py +266 -0
- package/pineforge_codegen/pine_spelling.py +216 -0
- package/pineforge_codegen/pragmas.py +64 -10
- package/pineforge_codegen/security_contexts.py +1585 -0
- package/pineforge_codegen/session_reads.py +84 -0
- package/pineforge_codegen/signatures.py +48 -23
- package/pineforge_codegen/support_checker.py +1106 -85
- package/pineforge_codegen-1.0.0.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +23 -21
- package/transpile.worker.mjs +24 -16
- package/pineforge_codegen-0.10.4.tar.gz +0 -0
|
@@ -8,7 +8,11 @@ own analyzer/codegen/signatures modules so the checker cannot drift.
|
|
|
8
8
|
Buckets:
|
|
9
9
|
|
|
10
10
|
* HARD_REJECT_FUNC / HARD_REJECT_NAMESPACE - calls that have no PineForge
|
|
11
|
-
semantics at all (e.g. ``request.
|
|
11
|
+
semantics at all (e.g. ``request.seed``, ``ticker.new``).
|
|
12
|
+
* NO_DATA_REQUEST_FUNC - requests PineForge has no data for
|
|
13
|
+
(``request.financial``, ...): refused only when their value can reach a
|
|
14
|
+
trade; one that reaches display and alert sinks only is lowered to na
|
|
15
|
+
(``external_requests``). ``request.security`` on another symbol too.
|
|
12
16
|
* DIVERGENT_VARS - built-in variables whose PineForge value diverges from
|
|
13
17
|
TradingView. They are reported as WARNING (e.g. ``bar_index`` and
|
|
14
18
|
``last_bar_index`` depend on the fed data window, ``timenow`` is not
|
|
@@ -18,7 +22,8 @@ Buckets:
|
|
|
18
22
|
* NOT_YET - calls the runtime could support but the transpiler does not yet
|
|
19
23
|
emit (e.g. ``max_bars_back``, bare ``barssince``).
|
|
20
24
|
* request.security - only ``symbol`` / ``timeframe`` / ``expression`` allowed,
|
|
21
|
-
symbol must be the current chart symbol
|
|
25
|
+
symbol must be the current chart symbol (a helper parameter's through its
|
|
26
|
+
call sites), or the value must reach display and alert sinks only.
|
|
22
27
|
* Declarations - only ``strategy(...)`` accepted; ``indicator(...)`` and
|
|
23
28
|
``library(...)`` rejected.
|
|
24
29
|
* Unknown ``ta.X`` / ``math.X`` / ``str.X`` / ``input.X`` calls (codegen would
|
|
@@ -27,11 +32,12 @@ Buckets:
|
|
|
27
32
|
|
|
28
33
|
from __future__ import annotations
|
|
29
34
|
|
|
35
|
+
import re
|
|
30
36
|
from typing import Callable
|
|
31
37
|
|
|
32
38
|
from .ast_nodes import (
|
|
33
39
|
ASTNode,
|
|
34
|
-
Program, StrategyDecl,
|
|
40
|
+
Program, StrategyDecl, ImportStmt, TypeField,
|
|
35
41
|
VarDecl, Assignment, TupleAssign,
|
|
36
42
|
IfStmt, ForStmt, ForInStmt, WhileStmt, SwitchStmt,
|
|
37
43
|
FuncDef, ExprStmt,
|
|
@@ -42,6 +48,16 @@ from .ast_nodes import (
|
|
|
42
48
|
TypeDecl, EnumDecl, MethodDef,
|
|
43
49
|
)
|
|
44
50
|
from .errors import SourceLocation, Diagnostic, CompileError, Level, Phase
|
|
51
|
+
from .builtin_keywords import POSITIONAL_BUILTINS
|
|
52
|
+
from .pine_spelling import expr_start
|
|
53
|
+
from .external_requests import (
|
|
54
|
+
ABSENT_LOWERING, CHART_FALLBACK_ANNOTATION, FEED_LOWERING, FEED_WARNING_ANNOTATION,
|
|
55
|
+
FOOTPRINT_COLUMN_ANNOTATION, LOWERING_ANNOTATION, NO_DATA_REQUEST_FUNCS,
|
|
56
|
+
RECORDED_LOWERING, FootprintValues, TradeSlice, footprint_column, recorded_key,
|
|
57
|
+
spell_call,
|
|
58
|
+
)
|
|
59
|
+
from .external_requests import _nodes as _walk_nodes
|
|
60
|
+
from .security_contexts import ticker_symbol_arg
|
|
45
61
|
from . import signatures as sigs
|
|
46
62
|
from .tv_input_choices import INPUT_SOURCE_SERIES_IDS
|
|
47
63
|
from .analyzer import TA_CLASS_MAP
|
|
@@ -68,7 +84,9 @@ TA_PROPERTY_VARIABLES: frozenset[str] = frozenset(
|
|
|
68
84
|
{"obv", "accdist", "nvi", "pvi", "pvt", "wad", "wvad", "iii"}
|
|
69
85
|
)
|
|
70
86
|
SUPPORTED_TA: frozenset[str] = frozenset(
|
|
71
|
-
set(TA_CLASS_MAP) - {
|
|
87
|
+
set(TA_CLASS_MAP) - {
|
|
88
|
+
"sum", "vwap_bands", "vwap_anchored", "vwap_anchored_bands"
|
|
89
|
+
} - set(TA_PROPERTY_VARIABLES) | {"tr", "pivot_point_levels"}
|
|
72
90
|
)
|
|
73
91
|
SUPPORTED_MATH: frozenset[str] = frozenset(
|
|
74
92
|
set(MATH_FUNC_MAP) | set(sigs.MATH_CONSTANTS) | set(sigs.MATH_FUNCTIONS)
|
|
@@ -113,8 +131,9 @@ _VISUAL_CONTAINER_TYPES: frozenset[str] = _DRAWING_TYPE_NAMES | frozenset({"tabl
|
|
|
113
131
|
SUPPORTED_COLOR_CONST: frozenset[str] = frozenset(COLOR_CONST_MAP)
|
|
114
132
|
SUPPORTED_COLOR_FUNC: frozenset[str] = frozenset({"new", "rgb", "r", "g", "b", "t"})
|
|
115
133
|
# Cosmetic color builders with no backtest-logic effect. Warned (not rejected);
|
|
116
|
-
# codegen
|
|
117
|
-
#
|
|
134
|
+
# codegen evaluates the arguments (their side effects and history are
|
|
135
|
+
# TradingView's) and emits a benign default color (0 = na color).
|
|
136
|
+
# color.from_gradient is a charting/plot helper that only tints visual output.
|
|
118
137
|
COSMETIC_COLOR_FUNC: frozenset[str] = frozenset({"from_gradient"})
|
|
119
138
|
SUPPORTED_TIMEFRAME_FUNC: frozenset[str] = frozenset({"change", "in_seconds"})
|
|
120
139
|
SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"})
|
|
@@ -124,11 +143,21 @@ SUPPORTED_RUNTIME_FUNC: frozenset[str] = frozenset({"error"})
|
|
|
124
143
|
# string statement that hides the typo from the strategy author.
|
|
125
144
|
SUPPORTED_LOG: frozenset[str] = frozenset({"info", "warning", "error"})
|
|
126
145
|
|
|
127
|
-
|
|
146
|
+
# Requests PineForge has no data for (``external_requests``): refused only
|
|
147
|
+
# when their value can reach a trade; one that reaches display and alert sinks
|
|
148
|
+
# only is lowered to na with a warning. ``request.security`` on another
|
|
149
|
+
# symbol follows the same rule when it reads no feed; ``request.footprint``
|
|
150
|
+
# as its whole expression reads the feed's delta column.
|
|
151
|
+
NO_DATA_REQUEST_FUNC: dict[str, str] = {
|
|
128
152
|
"request.financial": "External fundamentals data not available in PineForge.",
|
|
129
153
|
"request.dividends": "External corporate-action data not available in PineForge.",
|
|
130
154
|
"request.earnings": "External corporate-action data not available in PineForge.",
|
|
131
155
|
"request.splits": "External corporate-action data not available in PineForge.",
|
|
156
|
+
"request.footprint": "Footprint (bid/ask volume) data not available in PineForge.",
|
|
157
|
+
}
|
|
158
|
+
assert set(NO_DATA_REQUEST_FUNC) == {f"request.{n}" for n in NO_DATA_REQUEST_FUNCS}
|
|
159
|
+
|
|
160
|
+
HARD_REJECT_FUNC: dict[str, str] = {
|
|
132
161
|
"request.seed": "External seed data feeds not available in PineForge.",
|
|
133
162
|
"request.quandl": "External Quandl data not available in PineForge.",
|
|
134
163
|
"request.currency_rate": "Currency conversion data not available in PineForge.",
|
|
@@ -335,16 +364,29 @@ UNSUPPORTED_CONST_NAMESPACES: dict[str, str] = {
|
|
|
335
364
|
"xloc": _CONST_NS_VISUAL_MSG,
|
|
336
365
|
"yloc": _CONST_NS_VISUAL_MSG,
|
|
337
366
|
"barmerge": (
|
|
338
|
-
"is
|
|
339
|
-
"
|
|
340
|
-
"
|
|
367
|
+
"is not a barmerge constant; only barmerge.gaps_on, "
|
|
368
|
+
"barmerge.gaps_off, barmerge.lookahead_on and barmerge.lookahead_off "
|
|
369
|
+
"exist."
|
|
341
370
|
),
|
|
342
371
|
"alert": (
|
|
343
|
-
"is
|
|
344
|
-
"
|
|
372
|
+
"is not a Pine alert constant; only alert.freq_all, "
|
|
373
|
+
"alert.freq_once_per_bar and alert.freq_once_per_bar_close exist."
|
|
345
374
|
),
|
|
346
375
|
}
|
|
347
376
|
|
|
377
|
+
# ``barmerge.*`` are constants with a value: a script may hold one
|
|
378
|
+
# (TradingView/Request/3: ``var gapStrategy = gaps ? barmerge.gaps_on :
|
|
379
|
+
# barmerge.gaps_off``) and compare it. As a request's gaps or lookahead only
|
|
380
|
+
# the constant itself is read (``_check_request_security``).
|
|
381
|
+
BARMERGE_MEMBERS = frozenset({"gaps_on", "gaps_off", "lookahead_on", "lookahead_off"})
|
|
382
|
+
|
|
383
|
+
# ``alert.freq_*`` are const strings with a runtime value ("all",
|
|
384
|
+
# "once_per_bar", "once_per_bar_close" on TradingView's own tape), so they
|
|
385
|
+
# are ordinary values wherever they appear.
|
|
386
|
+
ALERT_FREQ_MEMBERS = frozenset({
|
|
387
|
+
"freq_all", "freq_once_per_bar", "freq_once_per_bar_close",
|
|
388
|
+
})
|
|
389
|
+
|
|
348
390
|
# Namespaces whose variable members have no batch-mode data source.
|
|
349
391
|
# These reads currently emit `std::string("<member>")` via the
|
|
350
392
|
# unknown-identifier fallthrough — which the analyzer-typed `double`
|
|
@@ -359,10 +401,10 @@ UNSUPPORTED_NAMESPACE_VARS: dict[str, str] = {
|
|
|
359
401
|
# Codegen supports symbol/timeframe/expression plus gaps/lookahead (read in
|
|
360
402
|
# _eval_security_* emission and forwarded to register_security_eval).
|
|
361
403
|
# `currency` is still rejected loudly (it changes the returned values via FX
|
|
362
|
-
# conversion, which codegen drops — silently wrong). `ignore_invalid_symbol`
|
|
363
|
-
# a
|
|
364
|
-
#
|
|
365
|
-
#
|
|
404
|
+
# conversion, which codegen drops — silently wrong). `ignore_invalid_symbol`
|
|
405
|
+
# is registered with a request of another symbol that reads its pinned feed
|
|
406
|
+
# (na for a symbol its facts say is invalid); the chart's own symbol is always
|
|
407
|
+
# valid, where the flag changes nothing.
|
|
366
408
|
SECURITY_ALLOWED_PARAMS: frozenset[str] = frozenset(
|
|
367
409
|
{"symbol", "timeframe", "expression", "gaps", "lookahead",
|
|
368
410
|
"ignore_invalid_symbol",
|
|
@@ -393,6 +435,77 @@ SECURITY_ADJUSTMENT_ALLOWED_VALUES: dict[str, frozenset[str]] = {
|
|
|
393
435
|
# Identifiers/expressions that resolve to "this script's symbol".
|
|
394
436
|
SECURITY_CURRENT_SYMBOL_NAMES: frozenset[str] = frozenset({"syminfo.tickerid", "syminfo.ticker"})
|
|
395
437
|
|
|
438
|
+
# Built-in namespaces a library import may alias, with their built-in
|
|
439
|
+
# members. TradingView's pine-facade compile of ``import TradingView/ta/7``
|
|
440
|
+
# links no library (``metaInfo.usedLibs`` stays empty) when the script names
|
|
441
|
+
# only built-in ``ta.*`` members, and links it for a library-only name such
|
|
442
|
+
# as ``ta.dema`` (XSYM-DESIGN report 1.3): such an import is a no-op. Every
|
|
443
|
+
# other import stays refused.
|
|
444
|
+
BUILTIN_NAMESPACE_IMPORT_MEMBERS: dict[str, frozenset[str]] = {
|
|
445
|
+
"ta": frozenset(sigs.TA_FUNCTIONS) | TA_PROPERTY_VARIABLES,
|
|
446
|
+
"math": frozenset(sigs.MATH_FUNCTIONS) | frozenset(sigs.MATH_CONSTANTS),
|
|
447
|
+
"str": frozenset(sigs.STR_FUNCTIONS),
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def import_spelling(node: ImportStmt) -> str:
|
|
452
|
+
"""The import as the refusal names it: ``user/name/version [as alias]``."""
|
|
453
|
+
return f"{node.path} as {node.alias}" if node.alias else node.path
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def import_refusal(node: ImportStmt) -> str:
|
|
457
|
+
"""The refusal of an import PineForge did not inline, with the reason the
|
|
458
|
+
library inliner recorded when library sources were configured but none
|
|
459
|
+
applies to the script (``pine_libraries``)."""
|
|
460
|
+
message = f"Import is not supported: '{import_spelling(node)}'"
|
|
461
|
+
reason = (node.annotations or {}).get("unresolved_reason")
|
|
462
|
+
return f"{message}: {reason}" if reason else message
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def import_is_builtin_namespace_no_op(program: Program, node: ImportStmt) -> bool:
|
|
466
|
+
"""``node`` imports a library under the name of a built-in namespace
|
|
467
|
+
(its alias, else its own name) and every member the script names through
|
|
468
|
+
that alias -- in a call, a read or a type -- is one of the namespace's
|
|
469
|
+
built-ins, so the library is never linked."""
|
|
470
|
+
alias = node.alias or node.name
|
|
471
|
+
members = BUILTIN_NAMESPACE_IMPORT_MEMBERS.get(alias or "")
|
|
472
|
+
if node.version is None or members is None:
|
|
473
|
+
return False
|
|
474
|
+
named = re.compile(rf"(?<![\w.]){re.escape(alias)}\.(\w+)")
|
|
475
|
+
|
|
476
|
+
def type_names(value) -> list[str]:
|
|
477
|
+
if isinstance(value, str):
|
|
478
|
+
return [value]
|
|
479
|
+
if isinstance(value, (list, tuple)):
|
|
480
|
+
return [item for item in value if isinstance(item, str)]
|
|
481
|
+
return []
|
|
482
|
+
|
|
483
|
+
stack: list = [program]
|
|
484
|
+
while stack:
|
|
485
|
+
item = stack.pop()
|
|
486
|
+
if isinstance(item, (list, tuple)):
|
|
487
|
+
stack.extend(item)
|
|
488
|
+
continue
|
|
489
|
+
if isinstance(item, dict):
|
|
490
|
+
stack.extend(item.values())
|
|
491
|
+
continue
|
|
492
|
+
if not isinstance(item, (ASTNode, TypeField)):
|
|
493
|
+
continue
|
|
494
|
+
if (isinstance(item, MemberAccess) and isinstance(item.object, Identifier)
|
|
495
|
+
and item.object.name == alias and item.member not in members):
|
|
496
|
+
return False
|
|
497
|
+
hints = [getattr(item, "type_hint", None), getattr(item, "type_name", None)]
|
|
498
|
+
notes = getattr(item, "annotations", None) or {}
|
|
499
|
+
hints += [notes.get("param_type_hints"), notes.get("template_args")]
|
|
500
|
+
for hint in hints:
|
|
501
|
+
for text in type_names(hint):
|
|
502
|
+
if any(m not in members for m in named.findall(text)):
|
|
503
|
+
return False
|
|
504
|
+
for key, value in vars(item).items():
|
|
505
|
+
if key not in ("loc", "annotations"):
|
|
506
|
+
stack.append(value)
|
|
507
|
+
return True
|
|
508
|
+
|
|
396
509
|
|
|
397
510
|
# ---------------------------------------------------------------------------
|
|
398
511
|
# Helpers
|
|
@@ -404,6 +517,18 @@ def _loc(node: ASTNode | None, fallback_file: str) -> SourceLocation:
|
|
|
404
517
|
return SourceLocation(file=fallback_file, line=1, col=1, end_col=1)
|
|
405
518
|
|
|
406
519
|
|
|
520
|
+
def _security_gaps_lookahead(node: FuncCall) -> tuple[ASTNode | None, ASTNode | None]:
|
|
521
|
+
"""A ``request.security`` call's gaps and lookahead arguments, positional
|
|
522
|
+
or keyword (None where it leaves one out)."""
|
|
523
|
+
gaps = node.kwargs.get("gaps")
|
|
524
|
+
if gaps is None and len(node.args) > 3:
|
|
525
|
+
gaps = node.args[3]
|
|
526
|
+
lookahead = node.kwargs.get("lookahead")
|
|
527
|
+
if lookahead is None and len(node.args) > 4:
|
|
528
|
+
lookahead = node.args[4]
|
|
529
|
+
return gaps, lookahead
|
|
530
|
+
|
|
531
|
+
|
|
407
532
|
def _qualified_name(callee: ASTNode) -> tuple[str | None, str | None]:
|
|
408
533
|
"""Return (namespace, function_name) for a call's callee, or (None, None)."""
|
|
409
534
|
if isinstance(callee, Identifier):
|
|
@@ -457,6 +582,9 @@ class SupportChecker:
|
|
|
457
582
|
for member, emission in SYMINFO_MEMBER_MAP.items()
|
|
458
583
|
if "na<" in emission or "get_syminfo_metadata" in emission
|
|
459
584
|
)
|
|
585
|
+
# Silent-gap fields a run declares as a symbol fact (a lane fact through
|
|
586
|
+
# the syminfo metadata channel): na only when the run declares none.
|
|
587
|
+
_SYMINFO_RUN_FACT_FIELDS: frozenset[str] = frozenset({"mincontract"})
|
|
460
588
|
|
|
461
589
|
def __init__(self, ast: Program, filename: str = "<input>") -> None:
|
|
462
590
|
self._ast = ast
|
|
@@ -498,12 +626,28 @@ class SupportChecker:
|
|
|
498
626
|
self._func_tuple_drawing_returns: dict[str, list[bool]] = {}
|
|
499
627
|
# Track whether we are inside an if/ternary condition expression.
|
|
500
628
|
self._in_conditional_depth: int = 0
|
|
629
|
+
# Inside a switch's non-default arm, which the checker used to skip:
|
|
630
|
+
# its findings warn (``_err``).
|
|
631
|
+
self._switch_arm_depth: int = 0
|
|
501
632
|
# Track whether we are inside an argument subtree that legitimately
|
|
502
633
|
# consumes constant-namespace members: parse-and-skip visual calls
|
|
503
634
|
# (plot, label.new, ...), the strategy() declaration, and
|
|
504
635
|
# request.security (barmerge.* gaps/lookahead values). While > 0 the
|
|
505
636
|
# UNSUPPORTED_CONST_NAMESPACES rejection is suppressed.
|
|
506
637
|
self._const_arg_ctx_depth: int = 0
|
|
638
|
+
# Inside the arguments of a request PineForge has no data for, whose
|
|
639
|
+
# field constants (``earnings.actual``) name what it would read.
|
|
640
|
+
self._request_field_ctx_depth: int = 0
|
|
641
|
+
# request.footprint calls that are the whole expression of a request
|
|
642
|
+
# of another symbol reading its feed (_lower_foreign_request).
|
|
643
|
+
self._feed_footprints: set[int] = set()
|
|
644
|
+
# Inside a request.security call's arguments (its expression).
|
|
645
|
+
self._security_payload_depth: int = 0
|
|
646
|
+
self._footprints: FootprintValues | bool | None = None
|
|
647
|
+
# Built on the first request with no data: which values reach a trade.
|
|
648
|
+
self._trade_slice: TradeSlice | None = None
|
|
649
|
+
# The helpers a request expression calls (_in_request_expression).
|
|
650
|
+
self._payload_funcs: set[str] | None = None
|
|
507
651
|
# id()s of Identifier/MemberAccess nodes that are the *callee* of a
|
|
508
652
|
# FuncCall. A divergent built-in NAME used as a call target (e.g. the
|
|
509
653
|
# session-aware ``time_close("D")`` function, which is distinct from the
|
|
@@ -524,12 +668,36 @@ class SupportChecker:
|
|
|
524
668
|
# (below) as well as during the visit, so a rebind that lexically
|
|
525
669
|
# FOLLOWS the request.security call cannot slip past.
|
|
526
670
|
self._scalar_rebinds: dict[str, list[ASTNode]] = {}
|
|
671
|
+
self._scalar_rebind_ids: set[int] = set()
|
|
672
|
+
# request.security needs lexical bindings rather than bare names: a
|
|
673
|
+
# function-local `sym := ...` cannot change an unrelated global `sym`.
|
|
674
|
+
# Indexed before the visit so rebinds after a security call count too.
|
|
675
|
+
self._security_symbol_refs: dict[int, object] = {}
|
|
676
|
+
self._security_symbol_defs: dict[object, ASTNode] = {}
|
|
677
|
+
self._security_symbol_rebinds: dict[object, list[ASTNode]] = {}
|
|
678
|
+
# Names declared ``var`` / ``varip`` anywhere: their declaration value
|
|
679
|
+
# is the first bar's only, so it does not describe later bars.
|
|
680
|
+
self._persistent_decl_names: set[str] = set()
|
|
681
|
+
# How many times each name is bound anywhere in the script -- a
|
|
682
|
+
# declaration, a callable parameter, a loop variable, a tuple element
|
|
683
|
+
# -- so a constant argument read through a name is trusted only when
|
|
684
|
+
# that name has exactly one binding and no scope can shadow it.
|
|
685
|
+
self._binding_counts: dict[str, int] = {}
|
|
686
|
+
# User functions by node id, and every call of each by name, so a
|
|
687
|
+
# request.security symbol that is a helper parameter resolves through
|
|
688
|
+
# the arguments of the helper's calls.
|
|
689
|
+
self._user_func_defs: dict[int, FuncDef] = {}
|
|
690
|
+
self._user_func_calls: dict[str, list[FuncCall]] = {}
|
|
691
|
+
self._overloaded_user_funcs: set[str] = set()
|
|
527
692
|
|
|
528
693
|
# -- Public API --
|
|
529
694
|
|
|
530
695
|
def check(self) -> list[Diagnostic]:
|
|
531
696
|
self._collect_user_definitions(self._ast)
|
|
697
|
+
self._collect_user_calls()
|
|
532
698
|
self._collect_scalar_rebinds(self._ast)
|
|
699
|
+
self._index_security_symbol_bindings()
|
|
700
|
+
self._count_bindings(self._ast)
|
|
533
701
|
for stmt in self._ast.body:
|
|
534
702
|
self._visit(stmt)
|
|
535
703
|
return self._diagnostics
|
|
@@ -552,9 +720,11 @@ class SupportChecker:
|
|
|
552
720
|
target = node.target
|
|
553
721
|
if not isinstance(target, Identifier) or node.value is None:
|
|
554
722
|
return
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
723
|
+
# A set, not a scan of the list: a name reassigned k times would
|
|
724
|
+
# otherwise cost k**2.
|
|
725
|
+
if id(node.value) not in self._scalar_rebind_ids:
|
|
726
|
+
self._scalar_rebind_ids.add(id(node.value))
|
|
727
|
+
self._scalar_rebinds.setdefault(target.name, []).append(node.value)
|
|
558
728
|
|
|
559
729
|
def _collect_scalar_rebinds(self, node: ASTNode) -> None:
|
|
560
730
|
"""Pre-pass: collect every scalar rebind anywhere in the AST.
|
|
@@ -578,6 +748,155 @@ class SupportChecker:
|
|
|
578
748
|
if isinstance(item, ASTNode):
|
|
579
749
|
self._collect_scalar_rebinds(item)
|
|
580
750
|
|
|
751
|
+
def _collect_user_calls(self) -> None:
|
|
752
|
+
"""Index the user functions and every call of each, anywhere."""
|
|
753
|
+
names: dict[str, int] = {}
|
|
754
|
+
for stmt in self._ast.body:
|
|
755
|
+
if isinstance(stmt, FuncDef):
|
|
756
|
+
self._user_func_defs[id(stmt)] = stmt
|
|
757
|
+
names[stmt.name] = names.get(stmt.name, 0) + 1
|
|
758
|
+
self._overloaded_user_funcs = {n for n, count in names.items() if count > 1}
|
|
759
|
+
stack: list = [self._ast]
|
|
760
|
+
while stack:
|
|
761
|
+
item = stack.pop()
|
|
762
|
+
if isinstance(item, (list, tuple)):
|
|
763
|
+
stack.extend(item)
|
|
764
|
+
continue
|
|
765
|
+
if isinstance(item, dict):
|
|
766
|
+
stack.extend(item.values())
|
|
767
|
+
continue
|
|
768
|
+
if not isinstance(item, ASTNode):
|
|
769
|
+
continue
|
|
770
|
+
if (isinstance(item, FuncCall) and isinstance(item.callee, Identifier)
|
|
771
|
+
and item.callee.name in names):
|
|
772
|
+
self._user_func_calls.setdefault(item.callee.name, []).append(item)
|
|
773
|
+
stack.extend(value for key, value in vars(item).items()
|
|
774
|
+
if key not in ("loc", "annotations"))
|
|
775
|
+
|
|
776
|
+
def _index_security_symbol_bindings(self) -> None:
|
|
777
|
+
"""Bind symbol reads and every ``:=`` to their lexical declaration.
|
|
778
|
+
|
|
779
|
+
Declarations in a function, branch or loop body shadow outer names;
|
|
780
|
+
reassignments without a local declaration resolve outwards. The
|
|
781
|
+
whole-block index keeps later rebinds visible to an earlier security
|
|
782
|
+
call, matching the existing conservative check.
|
|
783
|
+
"""
|
|
784
|
+
def resolve(name: str, scopes: tuple[dict[str, object], ...]) -> object | None:
|
|
785
|
+
for scope in reversed(scopes):
|
|
786
|
+
if name in scope:
|
|
787
|
+
return scope[name]
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
def walk_value(value: object, scopes: tuple[dict[str, object], ...]) -> None:
|
|
791
|
+
if isinstance(value, ASTNode):
|
|
792
|
+
walk(value, scopes)
|
|
793
|
+
elif isinstance(value, (list, tuple)):
|
|
794
|
+
for item in value:
|
|
795
|
+
walk_value(item, scopes)
|
|
796
|
+
elif isinstance(value, dict):
|
|
797
|
+
for item in value.values():
|
|
798
|
+
walk_value(item, scopes)
|
|
799
|
+
|
|
800
|
+
def block(statements: list, scopes: tuple[dict[str, object], ...],
|
|
801
|
+
binders: dict[str, object] | None = None) -> None:
|
|
802
|
+
scope = dict(binders or {})
|
|
803
|
+
for stmt in statements:
|
|
804
|
+
if isinstance(stmt, VarDecl) and stmt.name:
|
|
805
|
+
binding = id(stmt)
|
|
806
|
+
scope.setdefault(stmt.name, binding)
|
|
807
|
+
if stmt.value is not None:
|
|
808
|
+
self._security_symbol_defs[binding] = stmt.value
|
|
809
|
+
elif isinstance(stmt, TupleAssign):
|
|
810
|
+
for name in stmt.names:
|
|
811
|
+
scope.setdefault(name, ("tuple", id(stmt), name))
|
|
812
|
+
nested = (*scopes, scope)
|
|
813
|
+
for stmt in statements:
|
|
814
|
+
walk_value(stmt, nested)
|
|
815
|
+
|
|
816
|
+
def walk(node: ASTNode, scopes: tuple[dict[str, object], ...]) -> None:
|
|
817
|
+
if isinstance(node, Identifier):
|
|
818
|
+
binding = resolve(node.name, scopes)
|
|
819
|
+
if binding is not None:
|
|
820
|
+
self._security_symbol_refs[id(node)] = binding
|
|
821
|
+
return
|
|
822
|
+
if isinstance(node, Assignment):
|
|
823
|
+
if isinstance(node.target, Identifier) and node.value is not None:
|
|
824
|
+
binding = resolve(node.target.name, scopes)
|
|
825
|
+
if binding is not None:
|
|
826
|
+
self._security_symbol_rebinds.setdefault(binding, []).append(node.value)
|
|
827
|
+
walk_value(node.target, scopes)
|
|
828
|
+
walk_value(node.value, scopes)
|
|
829
|
+
return
|
|
830
|
+
if isinstance(node, (FuncDef, MethodDef)):
|
|
831
|
+
params = {name: ("param", id(node), name) for name in node.params}
|
|
832
|
+
block(node.body, scopes, params)
|
|
833
|
+
return
|
|
834
|
+
if isinstance(node, IfStmt):
|
|
835
|
+
walk_value(node.condition, scopes)
|
|
836
|
+
block(node.body, scopes)
|
|
837
|
+
block(node.else_body, scopes)
|
|
838
|
+
return
|
|
839
|
+
if isinstance(node, ForStmt):
|
|
840
|
+
for value in (node.start, node.end, node.step):
|
|
841
|
+
walk_value(value, scopes)
|
|
842
|
+
block(node.body, scopes, {node.var: ("loop", id(node), node.var)})
|
|
843
|
+
return
|
|
844
|
+
if isinstance(node, ForInStmt):
|
|
845
|
+
walk_value(node.iterable, scopes)
|
|
846
|
+
names = [node.var] if node.var else list(node.vars or ())
|
|
847
|
+
block(node.body, scopes, {
|
|
848
|
+
name: ("loop", id(node), name) for name in names
|
|
849
|
+
})
|
|
850
|
+
return
|
|
851
|
+
if isinstance(node, WhileStmt):
|
|
852
|
+
walk_value(node.condition, scopes)
|
|
853
|
+
block(node.body, scopes)
|
|
854
|
+
return
|
|
855
|
+
if isinstance(node, SwitchStmt):
|
|
856
|
+
walk_value(node.expr, scopes)
|
|
857
|
+
for case_expr, body in node.cases:
|
|
858
|
+
walk_value(case_expr, scopes)
|
|
859
|
+
block(body, scopes)
|
|
860
|
+
block(node.default_body, scopes)
|
|
861
|
+
return
|
|
862
|
+
for value in vars(node).values():
|
|
863
|
+
walk_value(value, scopes)
|
|
864
|
+
|
|
865
|
+
block(self._ast.body, ())
|
|
866
|
+
|
|
867
|
+
def _count_bindings(self, node) -> None:
|
|
868
|
+
"""Pre-pass: count every binding of every name (``_binding_counts``)."""
|
|
869
|
+
if isinstance(node, list):
|
|
870
|
+
for item in node:
|
|
871
|
+
self._count_bindings(item)
|
|
872
|
+
return
|
|
873
|
+
if not isinstance(node, ASTNode):
|
|
874
|
+
return
|
|
875
|
+
names: list = []
|
|
876
|
+
if isinstance(node, VarDecl):
|
|
877
|
+
names = [node.name]
|
|
878
|
+
elif isinstance(node, (FuncDef, MethodDef)):
|
|
879
|
+
names = list(node.params)
|
|
880
|
+
elif isinstance(node, ForStmt):
|
|
881
|
+
names = [node.var]
|
|
882
|
+
elif isinstance(node, ForInStmt):
|
|
883
|
+
names = [node.var] if node.var else list(node.vars or ())
|
|
884
|
+
elif isinstance(node, TupleAssign):
|
|
885
|
+
names = list(node.names)
|
|
886
|
+
for name in names:
|
|
887
|
+
name = name if isinstance(name, str) else getattr(name, "name", None)
|
|
888
|
+
if name:
|
|
889
|
+
self._binding_counts[name] = self._binding_counts.get(name, 0) + 1
|
|
890
|
+
for key, value in vars(node).items():
|
|
891
|
+
if key in ("loc", "annotations"):
|
|
892
|
+
continue
|
|
893
|
+
if key == "cases":
|
|
894
|
+
value = [item for case in value for item in case]
|
|
895
|
+
elif isinstance(value, dict):
|
|
896
|
+
value = list(value.values())
|
|
897
|
+
if isinstance(value, (ASTNode, list)):
|
|
898
|
+
self._count_bindings(value)
|
|
899
|
+
|
|
581
900
|
def _collect_user_definitions(self, ast: Program) -> None:
|
|
582
901
|
for stmt in ast.body:
|
|
583
902
|
if isinstance(stmt, TypeDecl):
|
|
@@ -825,7 +1144,10 @@ class SupportChecker:
|
|
|
825
1144
|
|
|
826
1145
|
def _err(self, node: ASTNode | None, message: str, hint: str | None = None) -> None:
|
|
827
1146
|
self._diagnostics.append(Diagnostic(
|
|
828
|
-
|
|
1147
|
+
# The checker never visited a switch's non-default arms: what it
|
|
1148
|
+
# finds there warns, and the arm keeps the lowering it compiled
|
|
1149
|
+
# to (``_visit_SwitchStmt``).
|
|
1150
|
+
level=Level.WARNING if self._switch_arm_depth else Level.ERROR,
|
|
829
1151
|
phase=Phase.ANALYZER,
|
|
830
1152
|
location=_loc(node, self._filename),
|
|
831
1153
|
message=message,
|
|
@@ -1006,9 +1328,16 @@ class SupportChecker:
|
|
|
1006
1328
|
# (e.g. scale=scale.right, format=format.price).
|
|
1007
1329
|
self._visit_children_const_ok(node)
|
|
1008
1330
|
|
|
1331
|
+
def _visit_ImportStmt(self, node: ImportStmt) -> None:
|
|
1332
|
+
if not import_is_builtin_namespace_no_op(self._ast, node):
|
|
1333
|
+
self._err(node, import_refusal(node))
|
|
1334
|
+
|
|
1009
1335
|
def _visit_VarDecl(self, node: VarDecl) -> None:
|
|
1336
|
+
self._check_tuple_literal_value(node.value)
|
|
1010
1337
|
if node.name and node.value is not None:
|
|
1011
1338
|
self._scalar_defs.setdefault(node.name, node.value)
|
|
1339
|
+
if node.name and (node.is_var or node.is_varip):
|
|
1340
|
+
self._persistent_decl_names.add(node.name)
|
|
1012
1341
|
if node.type_hint in self._user_types:
|
|
1013
1342
|
self._var_udt_types[node.name] = node.type_hint
|
|
1014
1343
|
elif isinstance(node.value, FuncCall):
|
|
@@ -1031,22 +1360,28 @@ class SupportChecker:
|
|
|
1031
1360
|
vns, vname = _qualified_name(node.value.callee)
|
|
1032
1361
|
if vname == "new" and vns in _VISUAL_CONTAINER_TYPES:
|
|
1033
1362
|
self._visual_container_vars.add(node.name)
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
"varip is not supported in PineForge batch backtests — there "
|
|
1038
|
-
"are no intrabar ticks. Codegen would silently demote varip "
|
|
1039
|
-
"to var, producing incorrect state accumulation for any "
|
|
1040
|
-
"script that relies on tick-level updates.",
|
|
1041
|
-
hint=(
|
|
1042
|
-
"Replace 'varip' with 'var' if the strategy logic does "
|
|
1043
|
-
"not depend on tick-level updates, or run the strategy "
|
|
1044
|
-
"in hosted TradingView Studio."
|
|
1045
|
-
),
|
|
1046
|
-
)
|
|
1363
|
+
# ``varip`` is supported: a historical bar executes once, so it keeps
|
|
1364
|
+
# its value like ``var``, and codegen leaves it out of the
|
|
1365
|
+
# calc_on_order_fills rollback (``_varip_state_member_names``).
|
|
1047
1366
|
self._visit_children(node)
|
|
1048
1367
|
|
|
1049
1368
|
def _visit_Assignment(self, node: Assignment) -> None:
|
|
1369
|
+
if isinstance(node.target, TupleLiteral):
|
|
1370
|
+
# ``[p, q] := f()`` reached the C++ as an assignment to a
|
|
1371
|
+
# temporary tuple, a silent no-op. TradingView rejects it
|
|
1372
|
+
# ("Syntax error at input ':='", CE10156): a tuple is declared
|
|
1373
|
+
# with ``=`` only.
|
|
1374
|
+
self._err(
|
|
1375
|
+
node,
|
|
1376
|
+
f"Tuple reassignment [..] {node.op} is not Pine syntax: "
|
|
1377
|
+
"TradingView declares a tuple with '=' and has no tuple "
|
|
1378
|
+
"reassignment.",
|
|
1379
|
+
hint="Declare new names with [a, b] = f(), then reassign "
|
|
1380
|
+
"each variable on its own line.",
|
|
1381
|
+
)
|
|
1382
|
+
self._visit_children(node)
|
|
1383
|
+
return
|
|
1384
|
+
self._check_tuple_literal_value(node.value)
|
|
1050
1385
|
# ``_scalar_defs`` records only the DECLARATION, so a later ``:=``
|
|
1051
1386
|
# rebind used to be invisible to the request.security symbol check.
|
|
1052
1387
|
# (``check`` also pre-collects these; recording here keeps the visit
|
|
@@ -1054,7 +1389,50 @@ class SupportChecker:
|
|
|
1054
1389
|
self._record_scalar_rebind(node)
|
|
1055
1390
|
self._visit_children(node)
|
|
1056
1391
|
|
|
1392
|
+
def _check_tuple_literal_value(self, value) -> None:
|
|
1393
|
+
"""Refuse a tuple literal or a ternary of tuples as a declaration's
|
|
1394
|
+
or reassignment's value.
|
|
1395
|
+
|
|
1396
|
+
TradingView takes a tuple literal only as a function's or an
|
|
1397
|
+
if/switch arm's value and as a request.security expression:
|
|
1398
|
+
``[a, b] = [close, open]`` and ``t = [close, open]`` are "Syntax
|
|
1399
|
+
error at input '['" (CE10156), ``[a, b] = c ? [x, y] : [y, x]`` is
|
|
1400
|
+
"Ternary operations cannot return tuples" (lab tv --no-note). The
|
|
1401
|
+
tuple declarations reached the C++ as a dropped statement ("/*
|
|
1402
|
+
unsupported tuple assignment */", their names left at 0), the plain
|
|
1403
|
+
declaration a tuple assigned to a double.
|
|
1404
|
+
"""
|
|
1405
|
+
if isinstance(value, TupleLiteral):
|
|
1406
|
+
self._err(
|
|
1407
|
+
value,
|
|
1408
|
+
"A tuple literal [..] cannot be a variable's value: TradingView "
|
|
1409
|
+
"rejects it here (Syntax error at input '[', CE10156). A tuple "
|
|
1410
|
+
"is only a function's or an if/switch arm's value or a "
|
|
1411
|
+
"request.security expression.",
|
|
1412
|
+
hint="Declare each variable on its own line (a = x, b = y), or "
|
|
1413
|
+
"return the tuple from a function: f() => [x, y], then "
|
|
1414
|
+
"[a, b] = f().",
|
|
1415
|
+
)
|
|
1416
|
+
elif isinstance(value, Ternary) and self._ternary_yields_tuple(value):
|
|
1417
|
+
self._err(
|
|
1418
|
+
value,
|
|
1419
|
+
"A ternary cannot return a tuple: TradingView rejects "
|
|
1420
|
+
"[a, b] = c ? [..] : [..] (\"Ternary operations cannot return "
|
|
1421
|
+
"tuples\").",
|
|
1422
|
+
hint="Select the tuple with an if or switch: [a, b] = if c, "
|
|
1423
|
+
"each arm ending in its tuple.",
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
@classmethod
|
|
1427
|
+
def _ternary_yields_tuple(cls, node) -> bool:
|
|
1428
|
+
return any(
|
|
1429
|
+
isinstance(arm, TupleLiteral)
|
|
1430
|
+
or (isinstance(arm, Ternary) and cls._ternary_yields_tuple(arm))
|
|
1431
|
+
for arm in (node.true_val, node.false_val)
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1057
1434
|
def _visit_TupleAssign(self, node: TupleAssign) -> None:
|
|
1435
|
+
self._check_tuple_literal_value(node.value)
|
|
1058
1436
|
drawing_mask = self._tuple_assign_drawing_mask(node.value)
|
|
1059
1437
|
if drawing_mask:
|
|
1060
1438
|
for idx, name in enumerate(node.names):
|
|
@@ -1119,6 +1497,42 @@ class SupportChecker:
|
|
|
1119
1497
|
|
|
1120
1498
|
full = f"{ns}.{name}" if ns else name
|
|
1121
1499
|
|
|
1500
|
+
# The footprint another symbol's feed carries: the column of the
|
|
1501
|
+
# request.security whose whole expression this is.
|
|
1502
|
+
if id(node) in self._feed_footprints:
|
|
1503
|
+
node.annotations = {**(node.annotations or {}),
|
|
1504
|
+
FOOTPRINT_COLUMN_ANNOTATION: footprint_column(node)}
|
|
1505
|
+
self._visit_request_arguments(node)
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1508
|
+
# A request PineForge has no data for: na when its value reaches
|
|
1509
|
+
# display sinks only, else its recorded series, or a deferred
|
|
1510
|
+
# refusal when no key names it.
|
|
1511
|
+
if ns == "request" and name in NO_DATA_REQUEST_FUNCS:
|
|
1512
|
+
if name == "footprint":
|
|
1513
|
+
self._lower_no_data_request(node, node, NO_DATA_REQUEST_FUNC[full])
|
|
1514
|
+
else:
|
|
1515
|
+
self._lower_recorded_request(node, NO_DATA_REQUEST_FUNC[full])
|
|
1516
|
+
self._visit_request_arguments(node)
|
|
1517
|
+
return
|
|
1518
|
+
|
|
1519
|
+
# A footprint value is its delta (external_requests.read_footprint_deltas):
|
|
1520
|
+
# every other member is refused by name.
|
|
1521
|
+
footprint = self._footprint_member(node)
|
|
1522
|
+
if footprint is not None:
|
|
1523
|
+
if footprint != "delta":
|
|
1524
|
+
self._err(
|
|
1525
|
+
node, f"footprint.{footprint}(...) is not supported.",
|
|
1526
|
+
hint=("PineForge reads a footprint's delta() only: the "
|
|
1527
|
+
"fp_delta_<ticks>_<va> column of the feed a requests manifest pins "
|
|
1528
|
+
"for the symbol requested."))
|
|
1529
|
+
receiver = node.callee.object
|
|
1530
|
+
if not (isinstance(receiver, Identifier) and receiver.name == "footprint"):
|
|
1531
|
+
self._visit(receiver)
|
|
1532
|
+
for arg in (*node.args, *node.kwargs.values()):
|
|
1533
|
+
self._visit(arg)
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1122
1536
|
# Hard rejects by full name.
|
|
1123
1537
|
if full in HARD_REJECT_FUNC:
|
|
1124
1538
|
self._err(node, f"{full}(...) is not supported.", hint=HARD_REJECT_FUNC[full])
|
|
@@ -1164,6 +1578,10 @@ class SupportChecker:
|
|
|
1164
1578
|
self._err(node, f"{name}(...) is not implemented yet.", hint=NOT_YET_FUNC[name])
|
|
1165
1579
|
self._visit_children(node)
|
|
1166
1580
|
return
|
|
1581
|
+
if (ns is None and name in POSITIONAL_BUILTINS
|
|
1582
|
+
and not self._check_builtin_arguments(node, name)):
|
|
1583
|
+
self._visit_children(node)
|
|
1584
|
+
return
|
|
1167
1585
|
|
|
1168
1586
|
# Bare cosmetic casts (e.g. `color(na)` -> default color). No backtest
|
|
1169
1587
|
# effect; warn and let codegen emit a benign default color.
|
|
@@ -1271,13 +1689,18 @@ class SupportChecker:
|
|
|
1271
1689
|
# validated above by _check_request_security and consumed by codegen.
|
|
1272
1690
|
if full == "request.security":
|
|
1273
1691
|
self._check_request_security(node)
|
|
1274
|
-
self.
|
|
1692
|
+
self._security_payload_depth += 1
|
|
1693
|
+
try:
|
|
1694
|
+
self._visit_children_const_ok(node)
|
|
1695
|
+
finally:
|
|
1696
|
+
self._security_payload_depth -= 1
|
|
1275
1697
|
return
|
|
1276
1698
|
# request.security_lower_tf — analyzer/codegen handle parameter validation
|
|
1277
1699
|
# and element-type rejection (UDT/color/string). Still validate the
|
|
1278
1700
|
# timeframe literal here so codegen catches malformed TF strings early.
|
|
1279
1701
|
if full == "request.security_lower_tf":
|
|
1280
1702
|
self._check_request_security_lower_tf_tf(node)
|
|
1703
|
+
self._check_request_security_lower_tf_symbol(node)
|
|
1281
1704
|
self._visit_children_const_ok(node)
|
|
1282
1705
|
return
|
|
1283
1706
|
if ns == "request":
|
|
@@ -1315,6 +1738,18 @@ class SupportChecker:
|
|
|
1315
1738
|
self._err(node, f"ta.{name}(...) is not implemented in PineForge runtime.")
|
|
1316
1739
|
self._visit_children(node)
|
|
1317
1740
|
return
|
|
1741
|
+
if ns == "ta":
|
|
1742
|
+
self._check_ta_call(node, name)
|
|
1743
|
+
if name == "ema":
|
|
1744
|
+
self._warn(
|
|
1745
|
+
node,
|
|
1746
|
+
"ta.ema initial warmup is approximated: PineForge may "
|
|
1747
|
+
"return a finite value before TradingView does.",
|
|
1748
|
+
hint="A covered length-20 TradingView tape first returns "
|
|
1749
|
+
"a finite EMA at bar 19; standalone PineForge EMA "
|
|
1750
|
+
"is finite at bar 0. Its established lowering stays "
|
|
1751
|
+
"in place pending per-call-site population parity.",
|
|
1752
|
+
)
|
|
1318
1753
|
if ns == "math" and name not in SUPPORTED_MATH:
|
|
1319
1754
|
self._err(node, f"math.{name}(...) is not implemented in PineForge runtime.")
|
|
1320
1755
|
self._visit_children(node)
|
|
@@ -1350,7 +1785,8 @@ class SupportChecker:
|
|
|
1350
1785
|
self._warn(
|
|
1351
1786
|
node,
|
|
1352
1787
|
f"color.{name}(...) has no effect in PineForge backtests "
|
|
1353
|
-
f"(visual only); it emits a
|
|
1788
|
+
f"(visual only); it evaluates its arguments and emits a "
|
|
1789
|
+
f"default color.",
|
|
1354
1790
|
)
|
|
1355
1791
|
self._visit_children(node)
|
|
1356
1792
|
return
|
|
@@ -1507,6 +1943,30 @@ class SupportChecker:
|
|
|
1507
1943
|
for stmt in node.else_body:
|
|
1508
1944
|
self._visit(stmt)
|
|
1509
1945
|
|
|
1946
|
+
def _visit_SwitchStmt(self, node: SwitchStmt) -> None:
|
|
1947
|
+
"""Visit a switch: its selector, every arm's condition and block, and
|
|
1948
|
+
its default block. ``cases`` holds ``(condition, block)`` tuples,
|
|
1949
|
+
which the generic child walk does not enter, so every arm but the
|
|
1950
|
+
default went unchecked: a request of another symbol in one kept no
|
|
1951
|
+
lowering (``TradingView/Request/3``'s ``cryptoDerivativeMetric``).
|
|
1952
|
+
Such an arm gets its lowerings now; what the checker would refuse
|
|
1953
|
+
there warns instead, so a script that compiled keeps compiling."""
|
|
1954
|
+
self._visit(node.expr)
|
|
1955
|
+
self._switch_arm_depth += 1
|
|
1956
|
+
try:
|
|
1957
|
+
for case_expr, body in node.cases:
|
|
1958
|
+
if node.expr is None:
|
|
1959
|
+
self._in_conditional_depth += 1
|
|
1960
|
+
self._visit(case_expr)
|
|
1961
|
+
if node.expr is None:
|
|
1962
|
+
self._in_conditional_depth -= 1
|
|
1963
|
+
for stmt in body:
|
|
1964
|
+
self._visit(stmt)
|
|
1965
|
+
finally:
|
|
1966
|
+
self._switch_arm_depth -= 1
|
|
1967
|
+
for stmt in node.default_body:
|
|
1968
|
+
self._visit(stmt)
|
|
1969
|
+
|
|
1510
1970
|
def _visit_Ternary(self, node: Ternary) -> None:
|
|
1511
1971
|
"""Visit ternary; mark the condition expression as conditional context."""
|
|
1512
1972
|
self._in_conditional_depth += 1
|
|
@@ -1564,13 +2024,15 @@ class SupportChecker:
|
|
|
1564
2024
|
lambda k, v: f"{k[0]}.{k[1]}: {v}",
|
|
1565
2025
|
):
|
|
1566
2026
|
return
|
|
1567
|
-
# Namespace-wide variable rejections (e.g. dividends.*, earnings.*)
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
2027
|
+
# Namespace-wide variable rejections (e.g. dividends.*, earnings.*),
|
|
2028
|
+
# but for the field argument of the request that reads them.
|
|
2029
|
+
if (isinstance(node.object, Identifier) and self._request_field_ctx_depth == 0
|
|
2030
|
+
and self._reject_if_in(
|
|
2031
|
+
UNSUPPORTED_NAMESPACE_VARS,
|
|
2032
|
+
node.object.name,
|
|
2033
|
+
node,
|
|
2034
|
+
lambda k, v: f"{k}.{node.member}: {v}",
|
|
2035
|
+
)):
|
|
1574
2036
|
return
|
|
1575
2037
|
# Constant-only namespace members (plot.style_*, text.align_*,
|
|
1576
2038
|
# barmerge.*, alert.freq_*, ...) used as FREE EXPRESSIONS. Inside
|
|
@@ -1579,6 +2041,10 @@ class SupportChecker:
|
|
|
1579
2041
|
if (
|
|
1580
2042
|
self._const_arg_ctx_depth == 0
|
|
1581
2043
|
and isinstance(node.object, Identifier)
|
|
2044
|
+
and not (node.object.name == "alert"
|
|
2045
|
+
and node.member in ALERT_FREQ_MEMBERS)
|
|
2046
|
+
and not (node.object.name == "barmerge"
|
|
2047
|
+
and node.member in BARMERGE_MEMBERS)
|
|
1582
2048
|
and self._reject_if_in(
|
|
1583
2049
|
UNSUPPORTED_CONST_NAMESPACES,
|
|
1584
2050
|
node.object.name,
|
|
@@ -1595,6 +2061,14 @@ class SupportChecker:
|
|
|
1595
2061
|
if isinstance(node.object, Identifier) and node.object.name == "syminfo":
|
|
1596
2062
|
if node.member not in SUPPORTED_SYMINFO:
|
|
1597
2063
|
self._err(node, f"syminfo.{node.member} is not implemented in PineForge runtime.")
|
|
2064
|
+
elif node.member in self._SYMINFO_RUN_FACT_FIELDS:
|
|
2065
|
+
# Read from the symbol fact the run declares; na without one.
|
|
2066
|
+
self._warn(
|
|
2067
|
+
node,
|
|
2068
|
+
f"syminfo.{node.member} is na unless the run declares the "
|
|
2069
|
+
f"symbol's {node.member} (syminfo metadata "
|
|
2070
|
+
f"\"{node.member}\", a lane fact).",
|
|
2071
|
+
)
|
|
1598
2072
|
elif node.member in self._SYMINFO_SILENT_GAP_FIELDS:
|
|
1599
2073
|
# These fields silently return na in current PineForge. Warn on
|
|
1600
2074
|
# EVERY read — not just inside an if/ternary condition — because
|
|
@@ -1653,12 +2127,22 @@ class SupportChecker:
|
|
|
1653
2127
|
elif "symbol" in node.kwargs:
|
|
1654
2128
|
symbol_node = node.kwargs["symbol"]
|
|
1655
2129
|
|
|
1656
|
-
if symbol_node is not None
|
|
1657
|
-
self.
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
2130
|
+
if symbol_node is not None:
|
|
2131
|
+
scoped_safe = self._is_current_symbol_expr(symbol_node)
|
|
2132
|
+
legacy_safe = self._is_current_symbol_expr(symbol_node, legacy_names=True)
|
|
2133
|
+
if not scoped_safe and not legacy_safe:
|
|
2134
|
+
# Another symbol: its pinned feed, when one can be read.
|
|
2135
|
+
self._lower_foreign_request(node, symbol_node)
|
|
2136
|
+
elif not scoped_safe or not self._is_current_symbol_expr(
|
|
2137
|
+
symbol_node, require_all_paths=True
|
|
2138
|
+
):
|
|
2139
|
+
if not self._lower_alternate_symbol(node, symbol_node):
|
|
2140
|
+
self._warn(
|
|
2141
|
+
symbol_node,
|
|
2142
|
+
"request.security symbol can select an alternate symbol, but "
|
|
2143
|
+
"PineForge always loads the current chart symbol.",
|
|
2144
|
+
hint="Every reachable symbol value must resolve to syminfo.tickerid or syminfo.ticker for exact results.",
|
|
2145
|
+
)
|
|
1662
2146
|
|
|
1663
2147
|
# timeframe literal-format check (positional [1] or kwarg).
|
|
1664
2148
|
tf_node = node.kwargs.get("timeframe")
|
|
@@ -1666,21 +2150,20 @@ class SupportChecker:
|
|
|
1666
2150
|
tf_node = node.args[1]
|
|
1667
2151
|
self._check_tf_literal(tf_node, "request.security")
|
|
1668
2152
|
|
|
1669
|
-
# gaps / lookahead value-shape check (positional or kwarg).
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
2153
|
+
# gaps / lookahead value-shape check (positional or kwarg). A request
|
|
2154
|
+
# that reads no data (lowered above) never reads them.
|
|
2155
|
+
reads_data = (node.annotations or {}).get(LOWERING_ANNOTATION) not in (
|
|
2156
|
+
"inert", "unpinned")
|
|
2157
|
+
gaps_node, lookahead_node = _security_gaps_lookahead(node)
|
|
2158
|
+
if (reads_data and gaps_node is not None
|
|
2159
|
+
and not self._is_barmerge_member(gaps_node, "gaps_on", "gaps_off")):
|
|
1674
2160
|
self._err(
|
|
1675
2161
|
gaps_node,
|
|
1676
2162
|
"request.security gaps must be barmerge.gaps_on or barmerge.gaps_off.",
|
|
1677
2163
|
hint="Codegen only recognizes the barmerge.gaps_* literal; other values are silently treated as gaps_off.",
|
|
1678
2164
|
)
|
|
1679
2165
|
|
|
1680
|
-
lookahead_node
|
|
1681
|
-
if lookahead_node is None and len(node.args) > 4:
|
|
1682
|
-
lookahead_node = node.args[4]
|
|
1683
|
-
if lookahead_node is not None and not self._is_barmerge_member(
|
|
2166
|
+
if reads_data and lookahead_node is not None and not self._is_barmerge_member(
|
|
1684
2167
|
lookahead_node, "lookahead_on", "lookahead_off"
|
|
1685
2168
|
):
|
|
1686
2169
|
self._err(
|
|
@@ -1716,6 +2199,260 @@ class SupportChecker:
|
|
|
1716
2199
|
# wrong-result bug. See SECURITY_ADJUSTMENT_ALLOWED_VALUES.
|
|
1717
2200
|
self._check_security_adjustment_kwargs(node)
|
|
1718
2201
|
|
|
2202
|
+
def _lower_no_data_request(self, node: FuncCall, at: ASTNode, why: str,
|
|
2203
|
+
reason: str | None | object = ...) -> None:
|
|
2204
|
+
"""A request PineForge has no data for: lowered to na, with a
|
|
2205
|
+
warning, when its value reaches display and alert sinks only; else a
|
|
2206
|
+
deferred refusal whose first read stops the run
|
|
2207
|
+
(``external_requests``). ``reason`` is the trade slice's, when the
|
|
2208
|
+
caller has it."""
|
|
2209
|
+
if self._trade_slice is None:
|
|
2210
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2211
|
+
if reason is ...:
|
|
2212
|
+
reason = self._trade_slice.reason(node)
|
|
2213
|
+
lowering = "inert" if reason is None else "unpinned"
|
|
2214
|
+
node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: lowering}
|
|
2215
|
+
if reason is None:
|
|
2216
|
+
self._warn(
|
|
2217
|
+
at,
|
|
2218
|
+
f"{spell_call(node)}: value reaches only display/alert sinks; "
|
|
2219
|
+
"lowered to na; trades are unaffected.",
|
|
2220
|
+
hint=f"{why} The value it lowers to reaches alerts, plots and tables only.",
|
|
2221
|
+
)
|
|
2222
|
+
else:
|
|
2223
|
+
self._warn(
|
|
2224
|
+
at,
|
|
2225
|
+
f"{spell_call(node)}: no data is pinned for this request; the run "
|
|
2226
|
+
"stops with an error where its value is read.",
|
|
2227
|
+
hint=f"{why} Its value can reach a trade: {reason}.",
|
|
2228
|
+
)
|
|
2229
|
+
|
|
2230
|
+
def _lower_foreign_request(self, node: FuncCall, symbol_node: ASTNode) -> None:
|
|
2231
|
+
"""``request.security`` of another symbol. Its value reaches display
|
|
2232
|
+
and alert sinks only: na, as before. Otherwise it reads the feed the
|
|
2233
|
+
probe's requests manifest pins for the symbol (``FEED_LOWERING``),
|
|
2234
|
+
with its first read stopping the run when none is installed; a shape
|
|
2235
|
+
registration cannot key keeps the deferred refusal."""
|
|
2236
|
+
if self._trade_slice is None:
|
|
2237
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2238
|
+
reason = self._trade_slice.reason(node)
|
|
2239
|
+
blocker = None if reason is None else self._foreign_feed_blocker(node, symbol_node)
|
|
2240
|
+
if reason is None or blocker is not None:
|
|
2241
|
+
self._lower_no_data_request(
|
|
2242
|
+
node, symbol_node,
|
|
2243
|
+
"PineForge backtests load the chart's symbol only "
|
|
2244
|
+
"(syminfo.tickerid, syminfo.ticker)"
|
|
2245
|
+
+ (f", and reads another symbol's feed only when {blocker}." if blocker
|
|
2246
|
+
else "."),
|
|
2247
|
+
reason=reason)
|
|
2248
|
+
return
|
|
2249
|
+
self._mark_feed(node)
|
|
2250
|
+
self._feed_warning(
|
|
2251
|
+
node, symbol_node,
|
|
2252
|
+
f"{spell_call(node)}: another symbol's bars, read from the feed the requests "
|
|
2253
|
+
"manifest pins for it; with none installed, the run stops with an error where "
|
|
2254
|
+
"its value is read.",
|
|
2255
|
+
hint=("PineForge runs the expression on that symbol's own bars (history, ta.* "
|
|
2256
|
+
"and syminfo.* in its context) and merges them by time as TradingView "
|
|
2257
|
+
"does; a symbol equal to the chart's reads the chart."),
|
|
2258
|
+
)
|
|
2259
|
+
|
|
2260
|
+
def _feed_warning(self, node: FuncCall, at: ASTNode, message: str, hint: str) -> None:
|
|
2261
|
+
"""Warn that ``node`` reads a feed -- or, when its symbol or
|
|
2262
|
+
timeframe reaches it through a helper's parameters, leave the warning
|
|
2263
|
+
to ``security_contexts``, which keys it on every call path or keeps
|
|
2264
|
+
its earlier lowering (``FEED_WARNING_ANNOTATION``)."""
|
|
2265
|
+
index = self._trade_slice.index
|
|
2266
|
+
symbol, tf = node.args[:2] if len(node.args) >= 2 else (
|
|
2267
|
+
node.args[0] if node.args else node.kwargs.get("symbol"),
|
|
2268
|
+
node.kwargs.get("timeframe"))
|
|
2269
|
+
if index.depends_on_scope(symbol) or index.depends_on_scope(tf):
|
|
2270
|
+
node.annotations = {**node.annotations,
|
|
2271
|
+
FEED_WARNING_ANNOTATION: (message, hint,
|
|
2272
|
+
_loc(at, self._filename))}
|
|
2273
|
+
return
|
|
2274
|
+
self._warn(at, message, hint=hint)
|
|
2275
|
+
|
|
2276
|
+
def _mark_feed(self, node: FuncCall) -> None:
|
|
2277
|
+
node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: FEED_LOWERING}
|
|
2278
|
+
payload = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
|
|
2279
|
+
if footprint_column(payload) is not None:
|
|
2280
|
+
self._feed_footprints.add(id(payload))
|
|
2281
|
+
|
|
2282
|
+
def _lower_alternate_symbol(self, node: FuncCall, symbol_node: ASTNode) -> bool:
|
|
2283
|
+
"""A symbol that can select the chart's or another symbol's, and
|
|
2284
|
+
whose value can reach a trade: registered by the string the run
|
|
2285
|
+
computes, the chart's reading the chart and another's its pinned feed
|
|
2286
|
+
(never the chart's bars in its place). False, keeping the chart's
|
|
2287
|
+
lowering with its warning, for one whose value reaches display sinks
|
|
2288
|
+
only or that registration cannot key."""
|
|
2289
|
+
if self._trade_slice is None:
|
|
2290
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2291
|
+
if (self._trade_slice.reason(node) is None
|
|
2292
|
+
or self._foreign_feed_blocker(node, symbol_node) is not None):
|
|
2293
|
+
return False
|
|
2294
|
+
self._mark_feed(node)
|
|
2295
|
+
node.annotations = {**node.annotations, CHART_FALLBACK_ANNOTATION: True}
|
|
2296
|
+
payload = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
|
|
2297
|
+
reads = ("a footprint reads the feed the requests manifest pins for each symbol, the "
|
|
2298
|
+
"chart's too (the chart's bars carry no footprint)"
|
|
2299
|
+
if footprint_column(payload) is not None else
|
|
2300
|
+
"the chart's reads the chart and another symbol's the feed the requests "
|
|
2301
|
+
"manifest pins for it")
|
|
2302
|
+
self._feed_warning(
|
|
2303
|
+
node, symbol_node,
|
|
2304
|
+
f"{spell_call(node)}: its symbol can select another symbol: registered by the "
|
|
2305
|
+
f"string the run computes, {reads}; with none installed, the run stops with an "
|
|
2306
|
+
"error where its value is read.",
|
|
2307
|
+
hint=("It read the chart's bars for every symbol: another symbol's value never "
|
|
2308
|
+
"comes from the chart."),
|
|
2309
|
+
)
|
|
2310
|
+
return True
|
|
2311
|
+
|
|
2312
|
+
def _lower_recorded_request(self, node: FuncCall, why: str) -> None:
|
|
2313
|
+
"""``request.earnings`` / ``dividends`` / ``splits`` / ``financial``.
|
|
2314
|
+
Its value reaches display and alert sinks only: na, as before.
|
|
2315
|
+
Otherwise it reads the series TradingView returned per chart bar,
|
|
2316
|
+
which the probe's requests manifest records under the request's key
|
|
2317
|
+
(``RECORDED_LOWERING``), its reads stopping the run when no series
|
|
2318
|
+
is installed; a spelling no key names keeps the deferred refusal."""
|
|
2319
|
+
if self._trade_slice is None:
|
|
2320
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2321
|
+
reason = self._trade_slice.reason(node)
|
|
2322
|
+
parts, blocker = recorded_key(node) if reason is not None else (None, None)
|
|
2323
|
+
if reason is not None and blocker is None and self._in_request_expression(node):
|
|
2324
|
+
blocker = "it is read outside a request.security expression"
|
|
2325
|
+
if reason is None or blocker is not None:
|
|
2326
|
+
self._lower_no_data_request(
|
|
2327
|
+
node, node, why + (f" A recorded series is read when {blocker}." if blocker
|
|
2328
|
+
else ""),
|
|
2329
|
+
reason=reason)
|
|
2330
|
+
return
|
|
2331
|
+
node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: RECORDED_LOWERING}
|
|
2332
|
+
key = (f"{parts['fn']}|<its symbol>|{parts['field']}|{parts['period']}|"
|
|
2333
|
+
f"gaps_{parts['gaps']}|lookahead_{parts['lookahead']}")
|
|
2334
|
+
self._warn(
|
|
2335
|
+
node,
|
|
2336
|
+
f"{spell_call(node)}: TradingView's values per chart bar, read from the series the "
|
|
2337
|
+
f"requests manifest records under {key}; with none installed, the run stops "
|
|
2338
|
+
"with an error where its value is read.",
|
|
2339
|
+
hint=("PineForge replays the values TradingView returned on the chart's own bars "
|
|
2340
|
+
"(chart_open_ms,value): a bar the tape has no row for reads na."),
|
|
2341
|
+
)
|
|
2342
|
+
|
|
2343
|
+
def _footprint_member(self, node: FuncCall) -> str | None:
|
|
2344
|
+
"""The member ``node`` calls on a footprint value (``fp.delta()``,
|
|
2345
|
+
``footprint.poc(fp)``), else None."""
|
|
2346
|
+
if not isinstance(node.callee, MemberAccess):
|
|
2347
|
+
return None
|
|
2348
|
+
if self._footprints is None:
|
|
2349
|
+
# Most scripts name no footprint: index none for them.
|
|
2350
|
+
mentions = any(
|
|
2351
|
+
(isinstance(n, Identifier) and n.name == "footprint")
|
|
2352
|
+
or (isinstance(n, MemberAccess) and n.member == "footprint")
|
|
2353
|
+
or (isinstance(n, VarDecl) and str(n.type_hint or "").strip() == "footprint")
|
|
2354
|
+
for n in _walk_nodes(self._ast))
|
|
2355
|
+
if not mentions:
|
|
2356
|
+
self._footprints = False
|
|
2357
|
+
else:
|
|
2358
|
+
if self._trade_slice is None:
|
|
2359
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2360
|
+
self._footprints = FootprintValues(self._trade_slice.index)
|
|
2361
|
+
if self._footprints is False:
|
|
2362
|
+
return None
|
|
2363
|
+
read = self._footprints.member(node)
|
|
2364
|
+
return read[0] if read is not None else None
|
|
2365
|
+
|
|
2366
|
+
def _foreign_feed_blocker(self, node: FuncCall, symbol_node: ASTNode) -> str | None:
|
|
2367
|
+
"""Why ``node`` cannot read another symbol's feed, or None: the
|
|
2368
|
+
engine registers a feed site for ``request.security`` alone, keyed by
|
|
2369
|
+
the symbol string and ``ignore_invalid_symbol`` as registration
|
|
2370
|
+
computes them before the first bar (a symbol reaching the request
|
|
2371
|
+
through a helper's parameters is resolved per call path, and falls
|
|
2372
|
+
back there), with no other request in its expression."""
|
|
2373
|
+
if _qualified_name(node.callee) != ("request", "security"):
|
|
2374
|
+
return "it is request.security"
|
|
2375
|
+
if self._in_request_expression(node):
|
|
2376
|
+
return "it is outside another request's expression"
|
|
2377
|
+
index = self._trade_slice.index
|
|
2378
|
+
if index.depends_on_scope(symbol_node):
|
|
2379
|
+
owner = index.owner.get(id(node))
|
|
2380
|
+
if owner not in index.funcs or owner in index.overloaded:
|
|
2381
|
+
# A method's parameter, a block's local: no call path to
|
|
2382
|
+
# resolve it through (``security_contexts``).
|
|
2383
|
+
return "registration computes its symbol before the first bar"
|
|
2384
|
+
elif not index.registration_value(symbol_node):
|
|
2385
|
+
return "registration computes its symbol before the first bar"
|
|
2386
|
+
tf = node.args[1] if len(node.args) > 1 else node.kwargs.get("timeframe")
|
|
2387
|
+
if (tf is not None and not index.depends_on_scope(tf)
|
|
2388
|
+
and not index.registers_timeframe(tf)):
|
|
2389
|
+
return "registration computes its timeframe before the first bar"
|
|
2390
|
+
ignore = node.kwargs.get("ignore_invalid_symbol")
|
|
2391
|
+
if ignore is not None and not index.registration_value(ignore):
|
|
2392
|
+
return "registration computes its ignore_invalid_symbol before the first bar"
|
|
2393
|
+
gaps, lookahead = _security_gaps_lookahead(node)
|
|
2394
|
+
if not (gaps is None or self._is_barmerge_member(gaps, "gaps_on", "gaps_off")) or not (
|
|
2395
|
+
lookahead is None
|
|
2396
|
+
or self._is_barmerge_member(lookahead, "lookahead_on", "lookahead_off")):
|
|
2397
|
+
# Registration reads them as the constants they are written as.
|
|
2398
|
+
return "its gaps and lookahead are barmerge constants"
|
|
2399
|
+
payload = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
|
|
2400
|
+
if isinstance(payload, FuncCall) and _qualified_name(payload.callee) == (
|
|
2401
|
+
"request", "footprint"):
|
|
2402
|
+
# The feed's footprint column: its name spells the literal ticks
|
|
2403
|
+
# per row and value-area percent.
|
|
2404
|
+
if footprint_column(payload) is None:
|
|
2405
|
+
return ("its request.footprint states ticks_per_row and va_percent as "
|
|
2406
|
+
"literal whole numbers")
|
|
2407
|
+
return None
|
|
2408
|
+
helpers = self._helpers_called(payload)
|
|
2409
|
+
for inner in _walk_nodes([payload, *(index.funcs[name] for name in helpers)]):
|
|
2410
|
+
if isinstance(inner, FuncCall) and _qualified_name(inner.callee)[0] == "request":
|
|
2411
|
+
return "its expression holds no request of its own"
|
|
2412
|
+
return None
|
|
2413
|
+
|
|
2414
|
+
def _helpers_called(self, value) -> set[str]:
|
|
2415
|
+
"""The helpers ``value`` calls, directly or through further helpers."""
|
|
2416
|
+
index = self._trade_slice.index
|
|
2417
|
+
|
|
2418
|
+
def callees(item) -> set[str]:
|
|
2419
|
+
return {n.callee.name for n in _walk_nodes(item)
|
|
2420
|
+
if isinstance(n, FuncCall) and id(n) in index.call_ids}
|
|
2421
|
+
|
|
2422
|
+
found: set[str] = set()
|
|
2423
|
+
pending = callees(value)
|
|
2424
|
+
while pending:
|
|
2425
|
+
name = pending.pop()
|
|
2426
|
+
if name not in found:
|
|
2427
|
+
found.add(name)
|
|
2428
|
+
pending |= callees(index.funcs[name]) - found
|
|
2429
|
+
return found
|
|
2430
|
+
|
|
2431
|
+
def _in_request_expression(self, node: FuncCall) -> bool:
|
|
2432
|
+
"""``node`` is evaluated inside a request's expression: written in it
|
|
2433
|
+
(a request's arguments are its only children the checker visits
|
|
2434
|
+
under ``_security_payload_depth``), or in a helper that expression
|
|
2435
|
+
calls, whose body runs inside the request's evaluator."""
|
|
2436
|
+
if self._security_payload_depth:
|
|
2437
|
+
return True
|
|
2438
|
+
if self._trade_slice is None:
|
|
2439
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2440
|
+
index = self._trade_slice.index
|
|
2441
|
+
if self._payload_funcs is None:
|
|
2442
|
+
self._payload_funcs = self._helpers_called([
|
|
2443
|
+
request.args[2] if len(request.args) > 2 else request.kwargs.get("expression")
|
|
2444
|
+
for requests in index.requests.values() for request in requests])
|
|
2445
|
+
return index.owner.get(id(node)) in self._payload_funcs
|
|
2446
|
+
|
|
2447
|
+
def _visit_request_arguments(self, node: FuncCall) -> None:
|
|
2448
|
+
"""A no-data request's arguments: ``barmerge.*`` and its field
|
|
2449
|
+
constants (``earnings.actual``) are what it reads."""
|
|
2450
|
+
self._request_field_ctx_depth += 1
|
|
2451
|
+
try:
|
|
2452
|
+
self._visit_children_const_ok(node)
|
|
2453
|
+
finally:
|
|
2454
|
+
self._request_field_ctx_depth -= 1
|
|
2455
|
+
|
|
1719
2456
|
def _check_security_adjustment_kwargs(self, node: FuncCall) -> None:
|
|
1720
2457
|
"""Reject request.security adjustment kwargs that the engine drops."""
|
|
1721
2458
|
for kw_name, allowed in SECURITY_ADJUSTMENT_ALLOWED_VALUES.items():
|
|
@@ -1741,6 +2478,195 @@ class SupportChecker:
|
|
|
1741
2478
|
hint=f"Allowed values: {sorted(allowed)}.",
|
|
1742
2479
|
)
|
|
1743
2480
|
|
|
2481
|
+
# -- ta.* call arguments --
|
|
2482
|
+
|
|
2483
|
+
def _check_ta_call(self, node: FuncCall, name: str) -> None:
|
|
2484
|
+
"""Bind the call's arguments to TradingView's signature, then flag (or,
|
|
2485
|
+
for a spelling that never compiled, refuse) a parameter value the
|
|
2486
|
+
engine's class cannot compute."""
|
|
2487
|
+
if not self._check_ta_arguments(node, name):
|
|
2488
|
+
return
|
|
2489
|
+
if name == "vwap":
|
|
2490
|
+
self._check_ta_vwap_anchor(node)
|
|
2491
|
+
elif name == "alma":
|
|
2492
|
+
self._check_ta_alma_floor(node)
|
|
2493
|
+
elif name in ("kc", "kcw"):
|
|
2494
|
+
self._check_ta_keltner_true_range(node, name)
|
|
2495
|
+
elif name == "pivot_point_levels":
|
|
2496
|
+
self._check_ta_pivot_point_levels(node)
|
|
2497
|
+
|
|
2498
|
+
@staticmethod
|
|
2499
|
+
def _ta_signature_text(name: str) -> str:
|
|
2500
|
+
"""TradingView's signature(s) of ``ta.<name>``, optional parameters
|
|
2501
|
+
with their defaults."""
|
|
2502
|
+
def spell(sig) -> str:
|
|
2503
|
+
parts = []
|
|
2504
|
+
for p in sig.params:
|
|
2505
|
+
if p.default is None:
|
|
2506
|
+
parts.append(p.name)
|
|
2507
|
+
elif isinstance(p.default, bool):
|
|
2508
|
+
parts.append(f"{p.name} = {'true' if p.default else 'false'}")
|
|
2509
|
+
else:
|
|
2510
|
+
parts.append(f"{p.name} = {p.default}")
|
|
2511
|
+
return f"ta.{name}({', '.join(parts)})"
|
|
2512
|
+
return " or ".join(spell(sig) for sig in sigs.TA_FUNCTIONS[name].signatures)
|
|
2513
|
+
|
|
2514
|
+
@staticmethod
|
|
2515
|
+
def _ta_binding_problem(sig, node: FuncCall):
|
|
2516
|
+
"""Why ``node``'s arguments do not bind to ``sig``, as ``(rank, message,
|
|
2517
|
+
node to point at)``, or None when they bind. A lower rank is the more
|
|
2518
|
+
useful report when no overload binds."""
|
|
2519
|
+
names = [p.name for p in sig.params]
|
|
2520
|
+
if len(node.args) > len(names):
|
|
2521
|
+
return ((2, 0), f"takes at most {len(names)} arguments, got {len(node.args)}",
|
|
2522
|
+
node.args[len(names)])
|
|
2523
|
+
for key, value in node.kwargs.items():
|
|
2524
|
+
if key not in names:
|
|
2525
|
+
return (3, 0), f"has no parameter '{key}'", value
|
|
2526
|
+
if names.index(key) < len(node.args):
|
|
2527
|
+
return (1, 0), f"got '{key}' twice, by position and by keyword", value
|
|
2528
|
+
bound = set(names[:len(node.args)]) | set(node.kwargs)
|
|
2529
|
+
missing = [p.name for p in sig.params if p.default is None and p.name not in bound]
|
|
2530
|
+
if missing:
|
|
2531
|
+
listed = ", ".join(f"'{m}'" for m in missing)
|
|
2532
|
+
noun = "arguments" if len(missing) > 1 else "argument"
|
|
2533
|
+
return (0, len(missing)), f"is missing its {noun} {listed}", None
|
|
2534
|
+
return None
|
|
2535
|
+
|
|
2536
|
+
def _check_builtin_arguments(self, node: FuncCall, name: str) -> bool:
|
|
2537
|
+
"""Refuse an ``nz`` / ``fixnan`` call whose arguments bind to none of
|
|
2538
|
+
TradingView's signatures -- ``nz(source)``, ``nz(source,
|
|
2539
|
+
replacement)``, ``fixnan(source)`` -- as TradingView does ("The nz
|
|
2540
|
+
function does not have an argument with the name x"). A call that
|
|
2541
|
+
binds is rewritten positionally by ``builtin_keywords``; before, a
|
|
2542
|
+
keyword crashed the codegen (IndexError) or was dropped. Returns True
|
|
2543
|
+
when the arguments bind.
|
|
2544
|
+
"""
|
|
2545
|
+
func = sigs.BUILTIN_FUNCTIONS[name]
|
|
2546
|
+
problems = []
|
|
2547
|
+
for sig in func.signatures:
|
|
2548
|
+
problem = self._ta_binding_problem(sig, node)
|
|
2549
|
+
if problem is None:
|
|
2550
|
+
return True
|
|
2551
|
+
problems.append((problem[0], -len(sig.params), problem[1], problem[2]))
|
|
2552
|
+
_rank, _width, message, at = min(problems, key=lambda p: (p[0], p[1]))
|
|
2553
|
+
spelled = " or ".join(
|
|
2554
|
+
f"{name}({', '.join(p.name for p in sig.params)})" for sig in func.signatures)
|
|
2555
|
+
self._err(
|
|
2556
|
+
expr_start(at if at is not None else node),
|
|
2557
|
+
f"{name} {message} (TradingView: {spelled}).",
|
|
2558
|
+
hint="Use the parameters of TradingView's signature.",
|
|
2559
|
+
)
|
|
2560
|
+
return False
|
|
2561
|
+
|
|
2562
|
+
def _check_ta_arguments(self, node: FuncCall, name: str) -> bool:
|
|
2563
|
+
"""Refuse a ``ta.*`` call whose arguments bind to none of TradingView's
|
|
2564
|
+
signatures (``signatures.TA_FUNCTIONS``), as TradingView does.
|
|
2565
|
+
|
|
2566
|
+
The analyzer routes the arguments by position, each to a constructor or
|
|
2567
|
+
``compute()`` slot. An argument that binds to no parameter used to be
|
|
2568
|
+
dropped (a misspelt or foreign keyword, a keyword given after the same
|
|
2569
|
+
positional), shifted into the next constructor slot (a keyword skipping
|
|
2570
|
+
a required parameter: ``ta.alma(close, 9, sigma=4)`` built
|
|
2571
|
+
``ALMA(9, 4)``) or passed to a ``compute()`` overload that does not
|
|
2572
|
+
exist (one too many, or a missing required one): a silently wrong
|
|
2573
|
+
indicator or a C++ compile failure. Returns True when they bind.
|
|
2574
|
+
"""
|
|
2575
|
+
func = sigs.TA_FUNCTIONS.get(name)
|
|
2576
|
+
if func is None:
|
|
2577
|
+
return True
|
|
2578
|
+
if name == "vwap" and not node.args and not node.kwargs:
|
|
2579
|
+
# ``ta.vwap()`` reads like the bare ``ta.vwap`` property (the VWAP
|
|
2580
|
+
# of hlc3), which is what it has always compiled to.
|
|
2581
|
+
return True
|
|
2582
|
+
problems = []
|
|
2583
|
+
for sig in func.signatures:
|
|
2584
|
+
problem = self._ta_binding_problem(sig, node)
|
|
2585
|
+
if problem is None:
|
|
2586
|
+
return True
|
|
2587
|
+
problems.append((problem[0], -len(sig.params), problem[1], problem[2]))
|
|
2588
|
+
_rank, _width, message, at = min(problems, key=lambda p: (p[0], p[1]))
|
|
2589
|
+
self._err(
|
|
2590
|
+
expr_start(at if at is not None else node),
|
|
2591
|
+
f"ta.{name} {message} (TradingView: {self._ta_signature_text(name)}).",
|
|
2592
|
+
hint="Use the parameters of TradingView's signature.",
|
|
2593
|
+
)
|
|
2594
|
+
return False
|
|
2595
|
+
|
|
2596
|
+
def _ta_argument(self, node: FuncCall, name: str, param: str):
|
|
2597
|
+
"""The argument bound to ``param`` (the call's arguments bind to the
|
|
2598
|
+
widest signature naming it), or None when it is omitted."""
|
|
2599
|
+
if param in node.kwargs:
|
|
2600
|
+
return node.kwargs[param]
|
|
2601
|
+
for sig in sorted(sigs.TA_FUNCTIONS[name].signatures, key=lambda s: -len(s.params)):
|
|
2602
|
+
names = [p.name for p in sig.params]
|
|
2603
|
+
if param in names:
|
|
2604
|
+
index = names.index(param)
|
|
2605
|
+
return node.args[index] if index < len(node.args) else None
|
|
2606
|
+
return None
|
|
2607
|
+
|
|
2608
|
+
def _is_constant_bool(self, node, value: bool, _seen: frozenset = frozenset()) -> bool:
|
|
2609
|
+
"""``node`` is the literal ``value``, directly or through a name bound
|
|
2610
|
+
exactly once in the script -- never reassigned, not ``var`` -- to it."""
|
|
2611
|
+
if isinstance(node, BoolLiteral):
|
|
2612
|
+
return node.value is value
|
|
2613
|
+
if isinstance(node, Identifier) and node.name not in _seen:
|
|
2614
|
+
if (self._binding_counts.get(node.name) != 1
|
|
2615
|
+
or node.name in self._scalar_rebinds
|
|
2616
|
+
or node.name in self._persistent_decl_names):
|
|
2617
|
+
return False
|
|
2618
|
+
definition = self._scalar_defs.get(node.name)
|
|
2619
|
+
return definition is not None and self._is_constant_bool(
|
|
2620
|
+
definition, value, _seen | {node.name})
|
|
2621
|
+
return False
|
|
2622
|
+
|
|
2623
|
+
def _check_ta_alma_floor(self, node: FuncCall) -> None:
|
|
2624
|
+
"""TA1's ``ta::ALMA`` accepts the Pine ``floor`` input exactly.
|
|
2625
|
+
|
|
2626
|
+
The generated compatibility shim keeps the same source compilable
|
|
2627
|
+
against an older engine and drops the argument there.
|
|
2628
|
+
"""
|
|
2629
|
+
return
|
|
2630
|
+
|
|
2631
|
+
def _check_ta_keltner_true_range(self, node: FuncCall, name: str) -> None:
|
|
2632
|
+
"""TA1's KC/KCW classes accept ``useTrueRange`` exactly."""
|
|
2633
|
+
return
|
|
2634
|
+
|
|
2635
|
+
def _check_ta_pivot_point_levels(self, node: FuncCall) -> None:
|
|
2636
|
+
"""TA1's ``PivotPointLevels`` accepts anchor and developing exactly."""
|
|
2637
|
+
return
|
|
2638
|
+
|
|
2639
|
+
# -- ta.vwap anchor --
|
|
2640
|
+
|
|
2641
|
+
# Timeframe strings naming the symbol's daily bar: ``ta.vwap``'s default
|
|
2642
|
+
# anchor is ``timeframe.change("1D")``.
|
|
2643
|
+
_VWAP_DAILY_ANCHOR_TFS = frozenset({"D", "1D"})
|
|
2644
|
+
|
|
2645
|
+
def _check_ta_vwap_anchor(self, node: FuncCall) -> None:
|
|
2646
|
+
"""TA1's anchored VWAP classes accept every Pine anchor expression.
|
|
2647
|
+
|
|
2648
|
+
The code generator keeps the historical class for the default daily
|
|
2649
|
+
anchor and emits the compatibility shim for every other anchor.
|
|
2650
|
+
"""
|
|
2651
|
+
return
|
|
2652
|
+
|
|
2653
|
+
def _is_daily_timeframe_change(self, node: ASTNode, _seen: frozenset = frozenset()) -> bool:
|
|
2654
|
+
"""``timeframe.change("1D")`` / ``("D")``, directly or through a
|
|
2655
|
+
never-reassigned, non-``var`` binding of it."""
|
|
2656
|
+
if isinstance(node, FuncCall):
|
|
2657
|
+
ns, fname = _qualified_name(node.callee)
|
|
2658
|
+
if (ns, fname) != ("timeframe", "change") or len(node.args) + len(node.kwargs) != 1:
|
|
2659
|
+
return False
|
|
2660
|
+
tf = node.args[0] if node.args else node.kwargs.get("timeframe")
|
|
2661
|
+
return isinstance(tf, StringLiteral) and tf.value in self._VWAP_DAILY_ANCHOR_TFS
|
|
2662
|
+
if isinstance(node, Identifier) and node.name not in _seen:
|
|
2663
|
+
if node.name in self._scalar_rebinds or node.name in self._persistent_decl_names:
|
|
2664
|
+
return False
|
|
2665
|
+
definition = self._scalar_defs.get(node.name)
|
|
2666
|
+
return definition is not None and self._is_daily_timeframe_change(
|
|
2667
|
+
definition, _seen | {node.name})
|
|
2668
|
+
return False
|
|
2669
|
+
|
|
1744
2670
|
def _is_barmerge_member(self, node: ASTNode, *allowed: str) -> bool:
|
|
1745
2671
|
if not isinstance(node, MemberAccess):
|
|
1746
2672
|
return False
|
|
@@ -1748,51 +2674,113 @@ class SupportChecker:
|
|
|
1748
2674
|
return False
|
|
1749
2675
|
return node.member in allowed
|
|
1750
2676
|
|
|
1751
|
-
def _is_current_symbol_expr(
|
|
1752
|
-
|
|
1753
|
-
|
|
2677
|
+
def _is_current_symbol_expr(
|
|
2678
|
+
self, node: ASTNode, _seen: frozenset[object] = frozenset(),
|
|
2679
|
+
*, require_all_paths: bool = False, legacy_names: bool = False,
|
|
2680
|
+
) -> bool:
|
|
1754
2681
|
chain = _resolve_member_chain(node)
|
|
1755
2682
|
if chain in SECURITY_CURRENT_SYMBOL_NAMES:
|
|
1756
2683
|
return True
|
|
1757
|
-
# ticker.inherit(
|
|
1758
|
-
#
|
|
1759
|
-
#
|
|
1760
|
-
#
|
|
1761
|
-
#
|
|
1762
|
-
#
|
|
2684
|
+
# ticker.inherit(from_tickerid, symbol) / ticker.standard(symbol) are
|
|
2685
|
+
# passthrough of their symbol (ticker_symbol_arg: inherit names it
|
|
2686
|
+
# second), and ticker.heikinashi(symbol) is the chart's OWN symbol with
|
|
2687
|
+
# a causal Heikin-Ashi candle transform (the engine applies it inside
|
|
2688
|
+
# the security eval via register_security_eval's heikinashi flag — no
|
|
2689
|
+
# alternate symbol is loaded). All allow a current-symbol symbol
|
|
2690
|
+
# argument; a non-current one is genuine cross-symbol construction and
|
|
2691
|
+
# still falls through.
|
|
1763
2692
|
if isinstance(node, FuncCall):
|
|
1764
2693
|
ns, fname = _qualified_name(node.callee)
|
|
1765
2694
|
if ns == "ticker" and fname in ("inherit", "standard", "heikinashi"):
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2695
|
+
symbol = ticker_symbol_arg(node)
|
|
2696
|
+
if symbol is not None and self._is_current_symbol_expr(
|
|
2697
|
+
symbol, _seen, require_all_paths=require_all_paths,
|
|
2698
|
+
legacy_names=legacy_names,
|
|
2699
|
+
):
|
|
1769
2700
|
return True
|
|
1770
|
-
#
|
|
1771
|
-
#
|
|
1772
|
-
# chart symbol, so this is exact at any config where the chosen branch is
|
|
1773
|
-
# the chart symbol; an unconditional cross-symbol expression still errors.
|
|
2701
|
+
# Preserve admission for a working script with an unreachable alternate
|
|
2702
|
+
# branch; the all-paths pass diagnoses a possibly wrong feed.
|
|
1774
2703
|
if isinstance(node, Ternary):
|
|
1775
|
-
|
|
1776
|
-
|
|
2704
|
+
true_safe = self._is_current_symbol_expr(
|
|
2705
|
+
node.true_val, _seen, require_all_paths=require_all_paths,
|
|
2706
|
+
legacy_names=legacy_names,
|
|
2707
|
+
)
|
|
2708
|
+
false_safe = self._is_current_symbol_expr(
|
|
2709
|
+
node.false_val, _seen, require_all_paths=require_all_paths,
|
|
2710
|
+
legacy_names=legacy_names,
|
|
2711
|
+
)
|
|
2712
|
+
return true_safe and false_safe if require_all_paths else true_safe or false_safe
|
|
1777
2713
|
# Def-use: resolve a bare identifier through its declaration value so an
|
|
1778
2714
|
# aliased symbol is accepted (``haTicker = ticker.heikinashi(...)`` then
|
|
1779
2715
|
# ``request.security(haTicker, ...)``). Every ``:=`` rebind of the name
|
|
1780
2716
|
# must resolve to the chart symbol too: the declaration alone does not
|
|
1781
|
-
# pin the value the call actually receives.
|
|
1782
|
-
if isinstance(node, Identifier)
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
_seen.add(node.name)
|
|
1787
|
-
if rebinds and not all(
|
|
1788
|
-
self._is_current_symbol_expr(value, _seen) for value in rebinds
|
|
1789
|
-
):
|
|
2717
|
+
# pin the value the call actually receives. Binding-cycle-guarded.
|
|
2718
|
+
if isinstance(node, Identifier):
|
|
2719
|
+
if legacy_names:
|
|
2720
|
+
binding = ("legacy", node.name)
|
|
2721
|
+
if binding in _seen:
|
|
1790
2722
|
return False
|
|
2723
|
+
definition = self._scalar_defs.get(node.name)
|
|
1791
2724
|
if definition is None:
|
|
1792
2725
|
return False
|
|
1793
|
-
|
|
2726
|
+
seen = _seen | {binding}
|
|
2727
|
+
return self._is_current_symbol_expr(
|
|
2728
|
+
definition, seen, require_all_paths=require_all_paths,
|
|
2729
|
+
legacy_names=True,
|
|
2730
|
+
) and all(
|
|
2731
|
+
self._is_current_symbol_expr(
|
|
2732
|
+
value, seen, require_all_paths=require_all_paths,
|
|
2733
|
+
legacy_names=True,
|
|
2734
|
+
)
|
|
2735
|
+
for value in self._scalar_rebinds.get(node.name, ())
|
|
2736
|
+
)
|
|
2737
|
+
binding = self._security_symbol_refs.get(id(node))
|
|
2738
|
+
if (isinstance(binding, tuple) and binding[0] == "param"
|
|
2739
|
+
and binding not in _seen):
|
|
2740
|
+
return self._param_is_current_symbol(
|
|
2741
|
+
binding, _seen | {binding}, require_all_paths)
|
|
2742
|
+
if binding is not None and binding not in _seen:
|
|
2743
|
+
definition = self._security_symbol_defs.get(binding)
|
|
2744
|
+
if definition is None:
|
|
2745
|
+
return False
|
|
2746
|
+
seen = _seen | {binding}
|
|
2747
|
+
if not self._is_current_symbol_expr(
|
|
2748
|
+
definition, seen, require_all_paths=require_all_paths
|
|
2749
|
+
):
|
|
2750
|
+
return False
|
|
2751
|
+
return all(
|
|
2752
|
+
self._is_current_symbol_expr(
|
|
2753
|
+
value, seen, require_all_paths=require_all_paths
|
|
2754
|
+
)
|
|
2755
|
+
for value in self._security_symbol_rebinds.get(binding, ())
|
|
2756
|
+
)
|
|
1794
2757
|
return False
|
|
1795
2758
|
|
|
2759
|
+
def _param_is_current_symbol(
|
|
2760
|
+
self, binding: tuple, seen: frozenset, require_all_paths: bool,
|
|
2761
|
+
) -> bool:
|
|
2762
|
+
"""A helper parameter is the chart's symbol when the argument every
|
|
2763
|
+
call of the helper binds to it is, read in the caller's scope --
|
|
2764
|
+
through further helpers' parameters too. A helper nothing calls
|
|
2765
|
+
never runs. A method's parameter, or an overloaded helper's, keeps
|
|
2766
|
+
the refusal."""
|
|
2767
|
+
_, func_id, name = binding
|
|
2768
|
+
fdef = self._user_func_defs.get(func_id)
|
|
2769
|
+
if fdef is None or fdef.name in self._overloaded_user_funcs:
|
|
2770
|
+
return False
|
|
2771
|
+
index = fdef.params.index(name)
|
|
2772
|
+
defaults = (fdef.annotations or {}).get("param_defaults") or []
|
|
2773
|
+
for call in self._user_func_calls.get(fdef.name, ()):
|
|
2774
|
+
arg = call.kwargs.get(name)
|
|
2775
|
+
if arg is None and index < len(call.args):
|
|
2776
|
+
arg = call.args[index]
|
|
2777
|
+
if arg is None and index < len(defaults):
|
|
2778
|
+
arg = defaults[index]
|
|
2779
|
+
if arg is None or not self._is_current_symbol_expr(
|
|
2780
|
+
arg, seen, require_all_paths=require_all_paths):
|
|
2781
|
+
return False
|
|
2782
|
+
return True
|
|
2783
|
+
|
|
1796
2784
|
# -- Pine timeframe-literal validation --
|
|
1797
2785
|
|
|
1798
2786
|
@staticmethod
|
|
@@ -1844,6 +2832,39 @@ class SupportChecker:
|
|
|
1844
2832
|
hint=err,
|
|
1845
2833
|
)
|
|
1846
2834
|
|
|
2835
|
+
def _check_request_security_lower_tf_symbol(self, node: FuncCall) -> None:
|
|
2836
|
+
"""``request.security_lower_tf`` reads the chart's intrabars: the
|
|
2837
|
+
engine loads no other symbol's. For another symbol whose value can
|
|
2838
|
+
reach a trade, evaluating the request stops the run with it named
|
|
2839
|
+
(``ABSENT_LOWERING``), never reading the chart's bars in its place;
|
|
2840
|
+
one reaching display and alert sinks only, or that can select the
|
|
2841
|
+
chart's symbol, keeps its lowering and warns."""
|
|
2842
|
+
symbol_node = node.args[0] if node.args else node.kwargs.get("symbol")
|
|
2843
|
+
if symbol_node is None or self._is_current_symbol_expr(
|
|
2844
|
+
symbol_node, require_all_paths=True):
|
|
2845
|
+
return
|
|
2846
|
+
if self._trade_slice is None:
|
|
2847
|
+
self._trade_slice = TradeSlice(self._ast)
|
|
2848
|
+
reason = self._trade_slice.reason(node)
|
|
2849
|
+
hint = "PineForge loads no other symbol's lower-timeframe bars."
|
|
2850
|
+
if (reason is not None and not self._is_current_symbol_expr(symbol_node)
|
|
2851
|
+
and not self._is_current_symbol_expr(symbol_node, legacy_names=True)):
|
|
2852
|
+
node.annotations = {**(node.annotations or {}), LOWERING_ANNOTATION: ABSENT_LOWERING}
|
|
2853
|
+
self._warn(
|
|
2854
|
+
symbol_node,
|
|
2855
|
+
f"{spell_call(node)}: no data is pinned for this request; the run stops with "
|
|
2856
|
+
"an error where it is evaluated.",
|
|
2857
|
+
hint=f"{hint} Its value can reach a trade: {reason}.")
|
|
2858
|
+
return
|
|
2859
|
+
self._warn(
|
|
2860
|
+
symbol_node,
|
|
2861
|
+
f"{spell_call(node)}: its symbol can be another symbol's, whose lower-timeframe "
|
|
2862
|
+
"bars PineForge reads from the chart"
|
|
2863
|
+
+ ("; its value reaches only display/alert sinks, so trades are unaffected."
|
|
2864
|
+
if reason is None else "."),
|
|
2865
|
+
hint=hint + " Every reachable symbol value must resolve to syminfo.tickerid or "
|
|
2866
|
+
"syminfo.ticker for exact results.")
|
|
2867
|
+
|
|
1847
2868
|
def _check_request_security_lower_tf_tf(self, node: FuncCall) -> None:
|
|
1848
2869
|
"""Validate the ``timeframe`` argument literal for
|
|
1849
2870
|
``request.security_lower_tf``. All other parameter validation lives in
|