@pineforge/codegen-pyodide 0.9.0 → 0.10.1
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/package.json +1 -1
- package/pineforge_codegen/__init__.py +3 -0
- package/pineforge_codegen/analyzer/base.py +3999 -140
- package/pineforge_codegen/analyzer/call_handlers.py +662 -48
- package/pineforge_codegen/analyzer/contracts.py +104 -7
- package/pineforge_codegen/analyzer/types.py +951 -14
- package/pineforge_codegen/codegen/base.py +2359 -249
- package/pineforge_codegen/codegen/drawing.py +94 -14
- package/pineforge_codegen/codegen/emit_top.py +732 -172
- package/pineforge_codegen/codegen/security.py +1456 -150
- package/pineforge_codegen/codegen/ta.py +435 -99
- package/pineforge_codegen/codegen/tables.py +224 -77
- package/pineforge_codegen/codegen/types.py +1196 -215
- package/pineforge_codegen/codegen/visit_call.py +1168 -99
- package/pineforge_codegen/codegen/visit_expr.py +354 -33
- package/pineforge_codegen/codegen/visit_stmt.py +1144 -200
- package/pineforge_codegen/finite_ta_length.py +798 -0
- package/pineforge_codegen/method_binding.py +163 -0
- package/pineforge_codegen/parser.py +48 -9
- package/pineforge_codegen/signatures.py +4 -4
- package/pineforge_codegen/support_checker.py +194 -19
- package/pineforge_codegen/symbols.py +65 -0
- package/pineforge_codegen-0.10.1.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +9 -11
- package/pineforge_codegen-0.9.0.tar.gz +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Strict, source-order-preserving binding for typed Pine methods.
|
|
2
|
+
|
|
3
|
+
Typed extension methods are inventoried before semantic analysis so a call may
|
|
4
|
+
precede its declaration without falling through to a same-named builtin. This
|
|
5
|
+
module deliberately contains no analyzer or codegen state: both phases consume
|
|
6
|
+
the same signature and binding result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from .ast_nodes import ASTNode, FuncCall, MethodDef, Program
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class MethodSignature:
|
|
18
|
+
key: str
|
|
19
|
+
param_names: tuple[str, ...]
|
|
20
|
+
param_defaults: tuple[ASTNode | None, ...]
|
|
21
|
+
param_type_hints: tuple[str | None, ...]
|
|
22
|
+
declaration: MethodDef | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class BoundMethodArgs:
|
|
27
|
+
"""Non-receiver arguments in parameter and evaluation order."""
|
|
28
|
+
|
|
29
|
+
args_by_param: tuple[ASTNode, ...]
|
|
30
|
+
evaluation_order: tuple[ASTNode, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MethodBindError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def signature_from_method_def(node: MethodDef) -> MethodSignature:
|
|
38
|
+
annotations = node.annotations or {}
|
|
39
|
+
defaults = list(annotations.get("param_defaults", ()))
|
|
40
|
+
hints = list(annotations.get("param_type_hints", ()))
|
|
41
|
+
while len(defaults) < len(node.params):
|
|
42
|
+
defaults.append(None)
|
|
43
|
+
while len(hints) < len(node.params):
|
|
44
|
+
hints.append(None)
|
|
45
|
+
return MethodSignature(
|
|
46
|
+
key=f"{node.type_name}.{node.name}",
|
|
47
|
+
param_names=tuple(node.params[1:]),
|
|
48
|
+
param_defaults=tuple(defaults[1:len(node.params)]),
|
|
49
|
+
param_type_hints=tuple(hints[1:len(node.params)]),
|
|
50
|
+
declaration=node,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def signature_from_callable(
|
|
55
|
+
key: str,
|
|
56
|
+
param_names: list[str] | tuple[str, ...],
|
|
57
|
+
param_defaults: list[ASTNode | None] | tuple[ASTNode | None, ...],
|
|
58
|
+
param_type_hints: list[str | None] | tuple[str | None, ...] = (),
|
|
59
|
+
) -> MethodSignature:
|
|
60
|
+
"""Build a non-receiver signature from an analyzed method FuncInfo."""
|
|
61
|
+
|
|
62
|
+
names = tuple(param_names)
|
|
63
|
+
defaults = list(param_defaults)
|
|
64
|
+
hints = list(param_type_hints)
|
|
65
|
+
while len(defaults) < len(names):
|
|
66
|
+
defaults.append(None)
|
|
67
|
+
while len(hints) < len(names):
|
|
68
|
+
hints.append(None)
|
|
69
|
+
return MethodSignature(
|
|
70
|
+
key=key,
|
|
71
|
+
param_names=names,
|
|
72
|
+
param_defaults=tuple(defaults[:len(names)]),
|
|
73
|
+
param_type_hints=tuple(hints[:len(names)]),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def inventory_method_signatures(program: Program) -> dict[str, MethodSignature]:
|
|
78
|
+
"""Inventory direct method declarations without analyzing their bodies."""
|
|
79
|
+
|
|
80
|
+
result: dict[str, MethodSignature] = {}
|
|
81
|
+
for stmt in program.body:
|
|
82
|
+
if isinstance(stmt, MethodDef):
|
|
83
|
+
signature = signature_from_method_def(stmt)
|
|
84
|
+
# Preserve the analyzer's historical first-declaration lookup. This
|
|
85
|
+
# prepass does not introduce method overloading or duplicate policy.
|
|
86
|
+
result.setdefault(signature.key, signature)
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _written_actuals(call: FuncCall) -> list[ASTNode]:
|
|
91
|
+
"""Recover written order lost by the parser's args/kwargs split."""
|
|
92
|
+
|
|
93
|
+
fallback = [*call.args, *call.kwargs.values()]
|
|
94
|
+
recorded = (call.annotations or {}).get("call_arg_order", ())
|
|
95
|
+
if (
|
|
96
|
+
len(recorded) == len(fallback)
|
|
97
|
+
and {id(node) for node in recorded} == {id(node) for node in fallback}
|
|
98
|
+
):
|
|
99
|
+
return list(recorded)
|
|
100
|
+
if not fallback or any(getattr(node, "loc", None) is None for node in fallback):
|
|
101
|
+
return fallback
|
|
102
|
+
indexed = list(enumerate(fallback))
|
|
103
|
+
indexed.sort(
|
|
104
|
+
key=lambda item: (
|
|
105
|
+
item[1].loc.line,
|
|
106
|
+
item[1].loc.col,
|
|
107
|
+
item[0],
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return [node for _index, node in indexed]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def bind_method_call(
|
|
114
|
+
signature: MethodSignature,
|
|
115
|
+
call: FuncCall,
|
|
116
|
+
) -> BoundMethodArgs:
|
|
117
|
+
"""Bind one method call strictly, excluding the receiver argument."""
|
|
118
|
+
|
|
119
|
+
names = signature.param_names
|
|
120
|
+
if len(call.args) > len(names):
|
|
121
|
+
raise MethodBindError(
|
|
122
|
+
f"{signature.key}: too many positional arguments "
|
|
123
|
+
f"(expected {len(names)}, got {len(call.args)})"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
for name in call.kwargs:
|
|
127
|
+
if name not in names:
|
|
128
|
+
raise MethodBindError(
|
|
129
|
+
f"{signature.key}: unknown keyword argument '{name}'"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
bound: list[ASTNode | None] = [None] * len(names)
|
|
133
|
+
for index, value in enumerate(call.args):
|
|
134
|
+
bound[index] = value
|
|
135
|
+
for name, value in call.kwargs.items():
|
|
136
|
+
index = names.index(name)
|
|
137
|
+
if bound[index] is not None:
|
|
138
|
+
raise MethodBindError(
|
|
139
|
+
f"{signature.key}: argument '{name}' passed both "
|
|
140
|
+
"positionally and by keyword"
|
|
141
|
+
)
|
|
142
|
+
bound[index] = value
|
|
143
|
+
|
|
144
|
+
inserted_defaults: list[ASTNode] = []
|
|
145
|
+
for index, name in enumerate(names):
|
|
146
|
+
if bound[index] is not None:
|
|
147
|
+
continue
|
|
148
|
+
default = (
|
|
149
|
+
signature.param_defaults[index]
|
|
150
|
+
if index < len(signature.param_defaults)
|
|
151
|
+
else None
|
|
152
|
+
)
|
|
153
|
+
if default is None:
|
|
154
|
+
raise MethodBindError(
|
|
155
|
+
f"{signature.key}: missing required argument '{name}'"
|
|
156
|
+
)
|
|
157
|
+
bound[index] = default
|
|
158
|
+
inserted_defaults.append(default)
|
|
159
|
+
|
|
160
|
+
return BoundMethodArgs(
|
|
161
|
+
args_by_param=tuple(value for value in bound if value is not None),
|
|
162
|
+
evaluation_order=tuple([*_written_actuals(call), *inserted_defaults]),
|
|
163
|
+
)
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import re
|
|
15
15
|
|
|
16
16
|
from .lexer import Token, TokenType
|
|
17
|
-
from .errors import SourceLocation
|
|
17
|
+
from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation
|
|
18
18
|
from .ast_nodes import (
|
|
19
19
|
ASTNode,
|
|
20
20
|
Program, StrategyDecl, ImportStmt,
|
|
@@ -56,6 +56,7 @@ class Parser:
|
|
|
56
56
|
self.pos = 0
|
|
57
57
|
self._source = source
|
|
58
58
|
self._filename = filename
|
|
59
|
+
self._recovery_count = 0
|
|
59
60
|
|
|
60
61
|
# ------------------------------------------------------------------
|
|
61
62
|
# Helpers
|
|
@@ -143,6 +144,9 @@ class Parser:
|
|
|
143
144
|
self._recover()
|
|
144
145
|
self._skip_newlines()
|
|
145
146
|
|
|
147
|
+
if self._recovery_count:
|
|
148
|
+
prog.annotations = dict(prog.annotations or {})
|
|
149
|
+
prog.annotations["parse_recovery_count"] = self._recovery_count
|
|
146
150
|
return prog
|
|
147
151
|
|
|
148
152
|
def _extract_version(self) -> int | None:
|
|
@@ -156,6 +160,7 @@ class Parser:
|
|
|
156
160
|
|
|
157
161
|
def _recover(self) -> None:
|
|
158
162
|
"""Skip tokens until next NEWLINE or EOF for error recovery."""
|
|
163
|
+
self._recovery_count += 1
|
|
159
164
|
while not self._at_end() and not self._check(TokenType.NEWLINE):
|
|
160
165
|
self._advance()
|
|
161
166
|
if self._check(TokenType.NEWLINE):
|
|
@@ -400,10 +405,13 @@ class Parser:
|
|
|
400
405
|
start_tok = self._advance() # consume 'strategy' or 'indicator'
|
|
401
406
|
# Parse arguments as a function call, then convert to StrategyDecl
|
|
402
407
|
self._consume(TokenType.LPAREN)
|
|
403
|
-
args, kwargs = self._parse_call_args()
|
|
408
|
+
args, kwargs, call_arg_order = self._parse_call_args()
|
|
404
409
|
self._consume(TokenType.RPAREN)
|
|
405
410
|
node = StrategyDecl(args=args, kwargs=kwargs)
|
|
406
|
-
node.annotations = {
|
|
411
|
+
node.annotations = {
|
|
412
|
+
"decl_kind": start_tok.value,
|
|
413
|
+
"call_arg_order": call_arg_order,
|
|
414
|
+
}
|
|
407
415
|
return self._set_loc(node, start_tok)
|
|
408
416
|
|
|
409
417
|
def _parse_import_stmt(self) -> ImportStmt:
|
|
@@ -786,8 +794,16 @@ class Parser:
|
|
|
786
794
|
start_tok = self._advance() # consume 'method'
|
|
787
795
|
name = self._consume(TokenType.IDENT).value
|
|
788
796
|
self._consume(TokenType.LPAREN)
|
|
789
|
-
# First param is the type + self
|
|
790
|
-
|
|
797
|
+
# First param is the required receiver type + self name. Receiver
|
|
798
|
+
# types use the same complete Pine hint grammar as ordinary typed
|
|
799
|
+
# parameters: primitives lex as TYPE_* tokens, while collections may
|
|
800
|
+
# carry nested ``<...>`` arguments or postfix ``[]``. Consuming only
|
|
801
|
+
# IDENT here silently dropped valid declarations such as
|
|
802
|
+
# ``method id(int self)`` and ``method push(array<int> self, ...)``
|
|
803
|
+
# through the parser's top-level recovery path.
|
|
804
|
+
if self._current().type not in TYPE_KEYWORDS | {TokenType.IDENT}:
|
|
805
|
+
self._consume(TokenType.IDENT) # raises a located ParseError
|
|
806
|
+
type_name = self._parse_type_hint_string()
|
|
791
807
|
params = [self._consume(TokenType.IDENT).value] # 'self' or user's name
|
|
792
808
|
param_type_hints = [type_name]
|
|
793
809
|
# Preserve per-param default expressions so codegen can substitute
|
|
@@ -1212,15 +1228,17 @@ class Parser:
|
|
|
1212
1228
|
"""Parse (args, kwargs) after callee expression."""
|
|
1213
1229
|
start_tok = self._current()
|
|
1214
1230
|
self._consume(TokenType.LPAREN)
|
|
1215
|
-
args, kwargs = self._parse_call_args()
|
|
1231
|
+
args, kwargs, call_arg_order = self._parse_call_args()
|
|
1216
1232
|
self._consume(TokenType.RPAREN)
|
|
1217
1233
|
node = FuncCall(callee=callee, args=args, kwargs=kwargs)
|
|
1234
|
+
node.annotations = {"call_arg_order": call_arg_order}
|
|
1218
1235
|
return self._set_loc(node, start_tok)
|
|
1219
1236
|
|
|
1220
|
-
def _parse_call_args(self) -> tuple[list, dict]:
|
|
1237
|
+
def _parse_call_args(self) -> tuple[list, dict, list]:
|
|
1221
1238
|
"""Parse function call arguments and keyword arguments."""
|
|
1222
1239
|
args: list = []
|
|
1223
1240
|
kwargs: dict = {}
|
|
1241
|
+
call_arg_order: list = []
|
|
1224
1242
|
|
|
1225
1243
|
while not self._check(TokenType.RPAREN) and not self._at_end():
|
|
1226
1244
|
# Detect kwargs: IDENT = value (but not IDENT == value)
|
|
@@ -1228,15 +1246,36 @@ class Parser:
|
|
|
1228
1246
|
and self._peek().type == TokenType.EQUALS
|
|
1229
1247
|
and self._peek(2).type != TokenType.EQUALS):
|
|
1230
1248
|
key_tok = self._advance()
|
|
1249
|
+
if key_tok.value in kwargs:
|
|
1250
|
+
raise CompileError(
|
|
1251
|
+
[
|
|
1252
|
+
Diagnostic(
|
|
1253
|
+
level=Level.ERROR,
|
|
1254
|
+
phase=Phase.PARSER,
|
|
1255
|
+
location=self._loc(key_tok),
|
|
1256
|
+
message=(
|
|
1257
|
+
"duplicate keyword argument "
|
|
1258
|
+
f"'{key_tok.value}'"
|
|
1259
|
+
),
|
|
1260
|
+
hint=(
|
|
1261
|
+
f"Remove one '{key_tok.value}=' binding; "
|
|
1262
|
+
"a keyword argument may be specified only once."
|
|
1263
|
+
),
|
|
1264
|
+
)
|
|
1265
|
+
]
|
|
1266
|
+
)
|
|
1231
1267
|
self._advance() # consume =
|
|
1232
1268
|
val = self._parse_expression()
|
|
1233
1269
|
kwargs[key_tok.value] = val
|
|
1270
|
+
call_arg_order.append(val)
|
|
1234
1271
|
else:
|
|
1235
|
-
|
|
1272
|
+
val = self._parse_expression()
|
|
1273
|
+
args.append(val)
|
|
1274
|
+
call_arg_order.append(val)
|
|
1236
1275
|
|
|
1237
1276
|
self._match(TokenType.COMMA)
|
|
1238
1277
|
|
|
1239
|
-
return args, kwargs
|
|
1278
|
+
return args, kwargs, call_arg_order
|
|
1240
1279
|
|
|
1241
1280
|
# -- Primary expressions --
|
|
1242
1281
|
|
|
@@ -229,7 +229,7 @@ _math("round",
|
|
|
229
229
|
_sig([("x", F)], ret=I), # round(x) → int
|
|
230
230
|
_sig([("x", F), ("precision", I)])) # round(x, n) → float
|
|
231
231
|
_math("round_to_mintick", _sig([("x", F)]))
|
|
232
|
-
_math("sign", _sig([("x", F)], ret=
|
|
232
|
+
_math("sign", _sig([("x", F)], ret=F))
|
|
233
233
|
_math("max",
|
|
234
234
|
_sig([("x", F), ("y", F)]), # 2-arg
|
|
235
235
|
_sig([("x", F), ("y", F), ("z", F)]), # 3-arg
|
|
@@ -307,13 +307,13 @@ _strat("exit", _sig([
|
|
|
307
307
|
_strat("close", _sig([
|
|
308
308
|
("id", S), ("comment", S, None),
|
|
309
309
|
("qty", F, None), ("qty_percent", F, None),
|
|
310
|
-
("alert_message", S, None), ("
|
|
311
|
-
("
|
|
310
|
+
("alert_message", S, None), ("immediately", B, False),
|
|
311
|
+
("disable_alert", B, None),
|
|
312
312
|
], ret=VOID))
|
|
313
313
|
|
|
314
314
|
_strat("close_all", _sig([
|
|
315
315
|
("comment", S, None), ("alert_message", S, None),
|
|
316
|
-
("
|
|
316
|
+
("immediately", B, False), ("disable_alert", B, None),
|
|
317
317
|
], ret=VOID))
|
|
318
318
|
|
|
319
319
|
_strat("cancel", _sig([("id", S)], ret=VOID))
|
|
@@ -10,14 +10,11 @@ Buckets:
|
|
|
10
10
|
* HARD_REJECT_FUNC / HARD_REJECT_NAMESPACE - calls that have no PineForge
|
|
11
11
|
semantics at all (e.g. ``request.financial``, ``ticker.*``).
|
|
12
12
|
* DIVERGENT_VARS - built-in variables whose PineForge value diverges from
|
|
13
|
-
TradingView.
|
|
14
|
-
data window, ``timenow`` is not
|
|
15
|
-
logging code that does not
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
they produce a plausible-looking but wrong value that flows straight into
|
|
19
|
-
trade logic, so a backtest would be silently wrong. Those are escalated to
|
|
20
|
-
ERROR (rejected) rather than merely warned.
|
|
13
|
+
TradingView. They are reported as WARNING (e.g. ``bar_index`` and
|
|
14
|
+
``last_bar_index`` depend on the fed data window, ``timenow`` is not
|
|
15
|
+
wall-clock) — these often appear in visual or logging code that does not
|
|
16
|
+
affect trade outcomes. DIVERGENT_VARS_ERROR is reserved for silent
|
|
17
|
+
mis-aliases severe enough to reject; it is currently empty.
|
|
21
18
|
* NOT_YET - calls the runtime could support but the transpiler does not yet
|
|
22
19
|
emit (e.g. ``max_bars_back``, bare ``barssince``).
|
|
23
20
|
* request.security - only ``symbol`` / ``timeframe`` / ``expression`` allowed,
|
|
@@ -81,6 +78,9 @@ SUPPORTED_INPUT: frozenset[str] = frozenset(sigs.INPUT_FUNCTIONS)
|
|
|
81
78
|
SUPPORTED_ARRAY: frozenset[str] = frozenset(set(ARRAY_METHODS) | {"new", "new_float", "new_int", "new_bool", "new_string", "from"} | set(ARRAY_DRAWING_NEW_CTORS))
|
|
82
79
|
SUPPORTED_MAP: frozenset[str] = frozenset(set(MAP_METHODS) | {"new"})
|
|
83
80
|
SUPPORTED_MATRIX: frozenset[str] = frozenset(set(MATRIX_METHODS) | {"new"})
|
|
81
|
+
_SUPPORTED_MAP_VALUE_TYPES: frozenset[str] = frozenset(
|
|
82
|
+
{"float", "int", "bool", "string"}
|
|
83
|
+
)
|
|
84
84
|
SUPPORTED_SYMINFO: frozenset[str] = frozenset(SYMINFO_MEMBER_MAP)
|
|
85
85
|
# Drawing-objects-as-data (spec §4.5). Geometry methods are REAL (route to the
|
|
86
86
|
# per-type arena); *_NOOP visual setters are accepted no-ops (Level.WARNING).
|
|
@@ -174,11 +174,11 @@ HARD_REJECT_NAMESPACE: dict[str, str] = {
|
|
|
174
174
|
# logging or visual logic that does not affect trade outcomes. The checker still
|
|
175
175
|
# flags divergence so users see the risk.
|
|
176
176
|
#
|
|
177
|
-
# DIVERGENT_VARS_ERROR is a SUBSET
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
177
|
+
# DIVERGENT_VARS_ERROR is a SUBSET reserved for silent MIS-ALIASES that must be
|
|
178
|
+
# escalated to ERROR (rejected), rather than merely warned. It is currently
|
|
179
|
+
# empty: last_bar_index lowers to the true final index of the fed data window,
|
|
180
|
+
# but that index can still differ from TradingView when the window does not
|
|
181
|
+
# cover the same chart history.
|
|
182
182
|
#
|
|
183
183
|
# NOTE: the bare ``time_close`` variable is NOT divergent — codegen lowers it to
|
|
184
184
|
# the engine's ``time_close()`` accessor, which returns the true bar-close
|
|
@@ -188,20 +188,32 @@ HARD_REJECT_NAMESPACE: dict[str, str] = {
|
|
|
188
188
|
# separate supported builtin handled in visit_call.)
|
|
189
189
|
DIVERGENT_VARS: dict[str, str] = {
|
|
190
190
|
"bar_index": "bar_index depends on the data window; PineForge and TradingView produce different values for the same script.",
|
|
191
|
-
"last_bar_index": "last_bar_index is
|
|
191
|
+
"last_bar_index": "last_bar_index is the final index of PineForge's fed data window; it can diverge from TradingView when the window does not cover the same chart history.",
|
|
192
192
|
"timenow": "timenow is aliased to the current bar timestamp in PineForge; it is not real wall-clock time.",
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
# Subset of DIVERGENT_VARS escalated from WARNING to ERROR (see comment above).
|
|
196
|
-
DIVERGENT_VARS_ERROR: frozenset[str] = frozenset(
|
|
196
|
+
DIVERGENT_VARS_ERROR: frozenset[str] = frozenset()
|
|
197
197
|
|
|
198
198
|
BARSTATE_APPROX_VARS: dict[str, str] = {
|
|
199
|
-
"barstate.islast":
|
|
199
|
+
"barstate.islast": (
|
|
200
|
+
"In direct batch chart-scope evaluation, barstate.islast is true only "
|
|
201
|
+
"on the final bar of PineForge's fed chart-data window. Inside a "
|
|
202
|
+
"request.security() history index, PineForge currently selects the "
|
|
203
|
+
"false branch; requested-context last-bar state is not modeled."
|
|
204
|
+
),
|
|
200
205
|
"barstate.ishistory": "barstate.ishistory is always true in PineForge batch backtests.",
|
|
201
206
|
"barstate.isrealtime": "barstate.isrealtime is always false in PineForge batch backtests.",
|
|
202
207
|
"barstate.isnew": "barstate.isnew follows PineForge first-tick execution state.",
|
|
203
208
|
"barstate.isconfirmed": "barstate.isconfirmed follows PineForge last-tick execution state.",
|
|
204
|
-
"barstate.islastconfirmedhistory":
|
|
209
|
+
"barstate.islastconfirmedhistory": (
|
|
210
|
+
"In direct historical batch chart-scope evaluation, PineForge "
|
|
211
|
+
"approximates barstate.islastconfirmedhistory as true only on the "
|
|
212
|
+
"final bar of the fed chart-data window. Inside a request.security() "
|
|
213
|
+
"history index, PineForge currently selects the false branch; "
|
|
214
|
+
"TradingView's requested-context and realtime-boundary semantics are "
|
|
215
|
+
"not modeled."
|
|
216
|
+
),
|
|
205
217
|
}
|
|
206
218
|
|
|
207
219
|
STRATEGY_UNSUPPORTED_PARAMS: dict[str, set[str]] = {
|
|
@@ -209,9 +221,13 @@ STRATEGY_UNSUPPORTED_PARAMS: dict[str, set[str]] = {
|
|
|
209
221
|
"order": {"comment", "alert_message", "disable_alert", "qty_type"},
|
|
210
222
|
"exit": {"comment_profit", "comment_loss", "comment_trailing", "alert_message", "alert_profit", "alert_loss", "alert_trailing", "disable_alert"},
|
|
211
223
|
"close": {"alert_message", "disable_alert"},
|
|
212
|
-
"close_all": {"
|
|
224
|
+
"close_all": {"alert_message", "disable_alert"},
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
# Alert delivery remains outside the backtest runtime contract. Keep these
|
|
228
|
+
# parameters warning-only so strategies that use webhook metadata still run;
|
|
229
|
+
# a follow-up must plumb alert events before codegen may claim support.
|
|
230
|
+
|
|
215
231
|
# strategy.closedtrades / strategy.opentrades accessor surfaces are NOT
|
|
216
232
|
# symmetric in Pine v6. opentrades has no exit_* fields (a trade has not
|
|
217
233
|
# closed yet). Both lack ``direction`` (Pine has ``size`` whose sign carries
|
|
@@ -461,6 +477,12 @@ class SupportChecker:
|
|
|
461
477
|
# shapes before codegen.
|
|
462
478
|
self._udt_drawing_fields: dict[str, set[str]] = {}
|
|
463
479
|
self._var_udt_types: dict[str, str] = {}
|
|
480
|
+
# PineMap IDs require runtime snapshots during COOF rollback. History
|
|
481
|
+
# buffers and generic matrices still value-copy their elements, so
|
|
482
|
+
# map-bearing shapes that would silently retain live aliases are gated
|
|
483
|
+
# until those containers have recursive checkpoint adapters.
|
|
484
|
+
self._udt_field_type_names: dict[str, dict[str, str]] = {}
|
|
485
|
+
self._map_bearing_udts: set[str] = set()
|
|
464
486
|
# Names (vars and function params) declared as a scalar visual-container
|
|
465
487
|
# type (table/box/line/label/linefill). A method call on one of these
|
|
466
488
|
# (``panel.cell(...)``) is a visual sink whose args may carry visual
|
|
@@ -515,6 +537,10 @@ class SupportChecker:
|
|
|
515
537
|
for stmt in ast.body:
|
|
516
538
|
if isinstance(stmt, TypeDecl):
|
|
517
539
|
self._user_types.add(stmt.name)
|
|
540
|
+
self._udt_field_type_names[stmt.name] = {
|
|
541
|
+
field.name: str(field.type_name or "").replace(" ", "")
|
|
542
|
+
for field in stmt.fields
|
|
543
|
+
}
|
|
518
544
|
drawing_fields = {
|
|
519
545
|
field.name
|
|
520
546
|
for field in stmt.fields
|
|
@@ -536,6 +562,140 @@ class SupportChecker:
|
|
|
536
562
|
self._user_methods.add(stmt.name)
|
|
537
563
|
self._collect_visual_container_params(stmt)
|
|
538
564
|
|
|
565
|
+
# Resolve direct and transitively nested map-bearing UDTs. Pine type
|
|
566
|
+
# declarations are normally dependency ordered, but the fixed point
|
|
567
|
+
# also keeps this guard deterministic for synthetic test ASTs.
|
|
568
|
+
changed = True
|
|
569
|
+
while changed:
|
|
570
|
+
changed = False
|
|
571
|
+
for udt_name, fields in self._udt_field_type_names.items():
|
|
572
|
+
bearing_fields = {
|
|
573
|
+
field_name
|
|
574
|
+
for field_name, type_name in fields.items()
|
|
575
|
+
if "map<" in type_name
|
|
576
|
+
or any(
|
|
577
|
+
type_name == nested
|
|
578
|
+
or f"<{nested}>" in type_name
|
|
579
|
+
or f"<{nested}," in type_name
|
|
580
|
+
or f",{nested}>" in type_name
|
|
581
|
+
for nested in self._map_bearing_udts
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
if bearing_fields:
|
|
585
|
+
if udt_name not in self._map_bearing_udts:
|
|
586
|
+
self._map_bearing_udts.add(udt_name)
|
|
587
|
+
changed = True
|
|
588
|
+
|
|
589
|
+
# Constructor checks alone are insufficient: a typed ``na``
|
|
590
|
+
# declaration, UDT field, or unused callable parameter can introduce
|
|
591
|
+
# the same unsupported runtime shape without ever calling map.new() or
|
|
592
|
+
# matrix.new(). Validate every declared type boundary after the UDT
|
|
593
|
+
# fixed point is known so unsupported shapes fail with a Pine source
|
|
594
|
+
# diagnostic instead of a generated-C++ static_assert.
|
|
595
|
+
for stmt in ast.body:
|
|
596
|
+
if isinstance(stmt, TypeDecl):
|
|
597
|
+
for field in stmt.fields:
|
|
598
|
+
self._validate_declared_type_hint(
|
|
599
|
+
stmt,
|
|
600
|
+
field.type_name,
|
|
601
|
+
context=f"field '{stmt.name}.{field.name}'",
|
|
602
|
+
)
|
|
603
|
+
elif isinstance(stmt, (FuncDef, MethodDef)):
|
|
604
|
+
hints = (
|
|
605
|
+
(getattr(stmt, "annotations", None) or {})
|
|
606
|
+
.get("param_type_hints")
|
|
607
|
+
or []
|
|
608
|
+
)
|
|
609
|
+
for index, param_name in enumerate(stmt.params):
|
|
610
|
+
hint = hints[index] if index < len(hints) else None
|
|
611
|
+
if hint:
|
|
612
|
+
self._validate_declared_type_hint(
|
|
613
|
+
stmt,
|
|
614
|
+
hint,
|
|
615
|
+
context=f"parameter '{param_name}'",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
@staticmethod
|
|
619
|
+
def _split_declared_type_args(text: str) -> list[str]:
|
|
620
|
+
"""Split ``K,V`` while preserving nested generic arguments."""
|
|
621
|
+
args: list[str] = []
|
|
622
|
+
current: list[str] = []
|
|
623
|
+
depth = 0
|
|
624
|
+
for char in text:
|
|
625
|
+
if char == "<":
|
|
626
|
+
depth += 1
|
|
627
|
+
elif char == ">":
|
|
628
|
+
depth -= 1
|
|
629
|
+
if char == "," and depth == 0:
|
|
630
|
+
args.append("".join(current))
|
|
631
|
+
current = []
|
|
632
|
+
else:
|
|
633
|
+
current.append(char)
|
|
634
|
+
if current:
|
|
635
|
+
args.append("".join(current))
|
|
636
|
+
return args
|
|
637
|
+
|
|
638
|
+
def _validate_declared_type_hint(
|
|
639
|
+
self,
|
|
640
|
+
node: ASTNode,
|
|
641
|
+
type_name: str | None,
|
|
642
|
+
*,
|
|
643
|
+
context: str,
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Fail closed for runtime collection limits at every type boundary."""
|
|
646
|
+
if not type_name:
|
|
647
|
+
return
|
|
648
|
+
compact = str(type_name).replace(" ", "")
|
|
649
|
+
if compact.endswith("[]"):
|
|
650
|
+
compact = f"array<{compact[:-2]}>"
|
|
651
|
+
|
|
652
|
+
if compact.startswith("map<") and compact.endswith(">"):
|
|
653
|
+
parts = self._split_declared_type_args(compact[4:-1])
|
|
654
|
+
if len(parts) != 2:
|
|
655
|
+
return
|
|
656
|
+
key_type, value_type = parts
|
|
657
|
+
if key_type != "string":
|
|
658
|
+
self._err(
|
|
659
|
+
node,
|
|
660
|
+
f"{context}: map keys must be string in PineForge's "
|
|
661
|
+
"supported map subset.",
|
|
662
|
+
)
|
|
663
|
+
if value_type not in _SUPPORTED_MAP_VALUE_TYPES:
|
|
664
|
+
self._err(
|
|
665
|
+
node,
|
|
666
|
+
f"{context}: map values must be primitive in PineForge's "
|
|
667
|
+
"supported map subset.",
|
|
668
|
+
)
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
if compact.startswith("matrix<") and compact.endswith(">"):
|
|
672
|
+
element_type = compact[len("matrix<"):-1]
|
|
673
|
+
if element_type in self._map_bearing_udts:
|
|
674
|
+
self._err(
|
|
675
|
+
node,
|
|
676
|
+
f"matrix<{element_type}> is not supported when the UDT "
|
|
677
|
+
"contains a map field; rollback would retain live map "
|
|
678
|
+
"aliases.",
|
|
679
|
+
hint="Use array<UDT> for recursively checkpointed state.",
|
|
680
|
+
)
|
|
681
|
+
return
|
|
682
|
+
if "<" in element_type:
|
|
683
|
+
self._err(
|
|
684
|
+
node,
|
|
685
|
+
f"matrix<{element_type}>: nested collection element types "
|
|
686
|
+
"not supported in v1.",
|
|
687
|
+
)
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
# Arrays are recursively checkpointed and may contain valid maps, but
|
|
691
|
+
# the nested map still has the same key/value boundary.
|
|
692
|
+
if compact.startswith("array<") and compact.endswith(">"):
|
|
693
|
+
self._validate_declared_type_hint(
|
|
694
|
+
node,
|
|
695
|
+
compact[len("array<"):-1],
|
|
696
|
+
context=context,
|
|
697
|
+
)
|
|
698
|
+
|
|
539
699
|
def _collect_visual_container_params(self, fn) -> None:
|
|
540
700
|
"""Register a function's parameters that are declared with a scalar
|
|
541
701
|
visual-container type (``table panel``, ``line ln``) so method calls on
|
|
@@ -814,6 +974,12 @@ class SupportChecker:
|
|
|
814
974
|
# so a direct ``dash.cell(..., text.align_left)`` is treated as a visual
|
|
815
975
|
# sink (mirrors the table/box/line/label/linefill PARAM tracking).
|
|
816
976
|
decl_hint = str(node.type_hint).replace(" ", "") if node.type_hint else None
|
|
977
|
+
if decl_hint:
|
|
978
|
+
self._validate_declared_type_hint(
|
|
979
|
+
node,
|
|
980
|
+
decl_hint,
|
|
981
|
+
context=f"variable '{node.name}'",
|
|
982
|
+
)
|
|
817
983
|
if decl_hint in _VISUAL_CONTAINER_TYPES:
|
|
818
984
|
self._visual_container_vars.add(node.name)
|
|
819
985
|
elif isinstance(node.value, FuncCall):
|
|
@@ -1160,7 +1326,7 @@ class SupportChecker:
|
|
|
1160
1326
|
targs = [str(t).replace(" ", "") for t in targs]
|
|
1161
1327
|
if targs and targs[0] != "string":
|
|
1162
1328
|
self._err(node, "map keys must be string in PineForge's supported map subset.")
|
|
1163
|
-
if len(targs) > 1 and targs[1] not in
|
|
1329
|
+
if len(targs) > 1 and targs[1] not in _SUPPORTED_MAP_VALUE_TYPES:
|
|
1164
1330
|
self._err(node, "map values must be primitive in PineForge's supported map subset.")
|
|
1165
1331
|
if ns == "matrix" and name == "new":
|
|
1166
1332
|
targs = (getattr(node.callee, "annotations", None) or {}).get("template_args") or []
|
|
@@ -1172,6 +1338,15 @@ class SupportChecker:
|
|
|
1172
1338
|
self._visit_children(node)
|
|
1173
1339
|
return
|
|
1174
1340
|
allowed_prim = {"float", "int", "bool", "string", "color"}
|
|
1341
|
+
if t in self._map_bearing_udts:
|
|
1342
|
+
self._err(
|
|
1343
|
+
node,
|
|
1344
|
+
f"matrix<{t}> is not supported when the UDT contains "
|
|
1345
|
+
"a map field; rollback would retain live map aliases.",
|
|
1346
|
+
hint="Use array<UDT> for recursively checkpointed state.",
|
|
1347
|
+
)
|
|
1348
|
+
self._visit_children(node)
|
|
1349
|
+
return
|
|
1175
1350
|
if t not in allowed_prim and t not in self._user_types:
|
|
1176
1351
|
self._err(node, f"matrix<{t}> element type not supported. Allowed: float, int, bool, string, color, or a declared UDT.")
|
|
1177
1352
|
self._visit_children(node)
|