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.
- pineforge_codegen/__init__.py +53 -0
- pineforge_codegen/analyzer/__init__.py +60 -0
- pineforge_codegen/analyzer/base.py +1563 -0
- pineforge_codegen/analyzer/call_handlers.py +895 -0
- pineforge_codegen/analyzer/contracts.py +163 -0
- pineforge_codegen/analyzer/diagnostics.py +118 -0
- pineforge_codegen/analyzer/tables.py +204 -0
- pineforge_codegen/analyzer/types.py +250 -0
- pineforge_codegen/ast_nodes.py +293 -0
- pineforge_codegen/codegen/__init__.py +78 -0
- pineforge_codegen/codegen/base.py +1381 -0
- pineforge_codegen/codegen/emit_top.py +875 -0
- pineforge_codegen/codegen/helpers.py +163 -0
- pineforge_codegen/codegen/helpers_syminfo.py +134 -0
- pineforge_codegen/codegen/input.py +189 -0
- pineforge_codegen/codegen/security.py +1564 -0
- pineforge_codegen/codegen/ta.py +298 -0
- pineforge_codegen/codegen/tables.py +613 -0
- pineforge_codegen/codegen/types.py +573 -0
- pineforge_codegen/codegen/visit_call.py +1305 -0
- pineforge_codegen/codegen/visit_expr.py +701 -0
- pineforge_codegen/codegen/visit_stmt.py +729 -0
- pineforge_codegen/errors.py +98 -0
- pineforge_codegen/lexer.py +531 -0
- pineforge_codegen/parser.py +1198 -0
- pineforge_codegen/pragmas.py +117 -0
- pineforge_codegen/signatures.py +808 -0
- pineforge_codegen/support_checker.py +1111 -0
- pineforge_codegen/symbols.py +118 -0
- pineforge_codegen/tokens.py +406 -0
- pineforge_codegen/tv_input_choices.py +86 -0
- pineforge_codegen-0.6.5.dist-info/METADATA +462 -0
- pineforge_codegen-0.6.5.dist-info/RECORD +35 -0
- pineforge_codegen-0.6.5.dist-info/WHEEL +4 -0
- 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()
|