@pineforge/codegen-pyodide 1.1.0 → 1.3.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/LICENSE +261 -0
- package/README.md +5 -0
- package/glue.py +4 -0
- package/package.json +6 -4
- package/pineforge_codegen/__init__.py +1 -0
- package/pineforge_codegen/codegen/base.py +48 -2
- package/pineforge_codegen/codegen/capabilities.py +319 -0
- package/pineforge_codegen/codegen/checked_settings.py +64 -5
- package/pineforge_codegen/codegen/emit_top.py +5 -3
- package/pineforge_codegen/codegen/helpers.py +7 -0
- package/pineforge_codegen/codegen/order_shapes.py +356 -0
- package/pineforge_codegen/codegen/security.py +54 -5
- package/pineforge_codegen/codegen/tables.py +12 -0
- package/pineforge_codegen/codegen/visit_call.py +61 -0
- package/pineforge_codegen/codegen/visit_expr.py +8 -2
- package/pineforge_codegen/codegen/visit_stmt.py +2 -4
- package/pineforge_codegen/diagnostic_codes.py +341 -0
- package/pineforge_codegen/diagnostics_catalog.json +631 -0
- package/pineforge_codegen/errors.py +23 -0
- package/pineforge_codegen/external_requests.py +16 -0
- package/pineforge_codegen/support_checker.py +19 -10
- package/pineforge_codegen-1.3.0.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +1 -1
- package/transpile.worker.mjs +4 -0
- package/pineforge_codegen-1.1.0.tar.gz +0 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Immutable facts about emitted order parameters and execution context."""
|
|
2
|
+
|
|
3
|
+
from collections import ChainMap, Counter
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from ..ast_nodes import (
|
|
10
|
+
BinOp, BoolLiteral, ForInStmt, ForStmt, FuncCall, FuncDef, Identifier, IfStmt,
|
|
11
|
+
MemberAccess, MethodDef, NumberLiteral, StringLiteral, SwitchStmt, TupleAssign,
|
|
12
|
+
UnaryOp, VarDecl, WhileStmt,
|
|
13
|
+
)
|
|
14
|
+
from ..limits import iter_ast_nodes, syntax_children
|
|
15
|
+
from .capabilities import READ_ONLY_STRATEGY_CALLS, _expression
|
|
16
|
+
from .host_members import HOST_MEMBER_NAMES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
NUMERIC_PARAMETERS = frozenset((
|
|
20
|
+
"limit", "stop", "qty", "qty_percent", "trail_points", "trail_offset",
|
|
21
|
+
"trail_price", "profit_ticks", "loss_ticks",
|
|
22
|
+
))
|
|
23
|
+
FINITE_BAR_VALUES = frozenset(("open", "high", "low", "close", "hl2", "hlc3", "ohlc4", "hlcc4"))
|
|
24
|
+
ENUM_VALUES = {
|
|
25
|
+
"strategy.oca.none": 0, "strategy.oca.cancel": 1, "strategy.oca.reduce": 2,
|
|
26
|
+
"strategy.fixed": 0, "strategy.percent_of_equity": 1, "strategy.cash": 2,
|
|
27
|
+
}
|
|
28
|
+
SETTING_DEFAULTS = {
|
|
29
|
+
"initial_capital": 10000.0, "default_qty_type": "fixed", "default_qty_value": 1.0,
|
|
30
|
+
"pyramiding": 1, "commission_type": "percent", "commission_value": 0.0,
|
|
31
|
+
"slippage": 0, "margin_long": 100.0, "margin_short": 100.0,
|
|
32
|
+
"close_entries_rule": "FIFO",
|
|
33
|
+
}
|
|
34
|
+
SETTING_ENUMS = {
|
|
35
|
+
"QtyType::FIXED": "fixed", "QtyType::PERCENT_OF_EQUITY": "percent_of_equity",
|
|
36
|
+
"QtyType::CASH": "cash", "CommissionType::PERCENT": "percent",
|
|
37
|
+
"CommissionType::CASH_PER_ORDER": "cash_per_order",
|
|
38
|
+
"CommissionType::CASH_PER_CONTRACT": "cash_per_contract",
|
|
39
|
+
}
|
|
40
|
+
CPP_TOKENS = re.compile(
|
|
41
|
+
r'R"(?P<delimiter>[^ ()\\\t\r\n]{0,16})\(.*?\)(?P=delimiter)"'
|
|
42
|
+
r'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''
|
|
43
|
+
r'|//[^\n]*|/\*.*?(?:\*/|\Z)|[A-Za-z_][A-Za-z_0-9]*',
|
|
44
|
+
re.DOTALL,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan_host_reads(cpp: str) -> set[str]:
|
|
49
|
+
"""Cross-check host tokens, excluding C++ strings and comments."""
|
|
50
|
+
return {match.group() for match in CPP_TOKENS.finditer(cpp)
|
|
51
|
+
if match.group() in HOST_MEMBER_NAMES}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HostReadLines(list):
|
|
55
|
+
"""Record scaffold references when each fragment enters the emission buffer."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, reads: set[str]):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.reads = reads
|
|
60
|
+
self.in_comment = False
|
|
61
|
+
|
|
62
|
+
def _record(self, fragment: str) -> None:
|
|
63
|
+
if self.in_comment:
|
|
64
|
+
end = fragment.find("*/")
|
|
65
|
+
if end < 0:
|
|
66
|
+
return
|
|
67
|
+
fragment = fragment[end + 2:]
|
|
68
|
+
self.in_comment = False
|
|
69
|
+
for match in CPP_TOKENS.finditer(fragment):
|
|
70
|
+
token = match.group()
|
|
71
|
+
if token in HOST_MEMBER_NAMES:
|
|
72
|
+
self.reads.add(token)
|
|
73
|
+
if token.startswith("/*") and not token.endswith("*/"):
|
|
74
|
+
self.in_comment = True
|
|
75
|
+
|
|
76
|
+
def append(self, fragment: str) -> None:
|
|
77
|
+
self._record(fragment)
|
|
78
|
+
super().append(fragment)
|
|
79
|
+
|
|
80
|
+
def extend(self, fragments) -> None:
|
|
81
|
+
if fragments is self:
|
|
82
|
+
fragments = list(fragments)
|
|
83
|
+
for fragment in fragments:
|
|
84
|
+
self.append(fragment)
|
|
85
|
+
|
|
86
|
+
def _record_rewrite(self) -> None:
|
|
87
|
+
self.in_comment = False
|
|
88
|
+
for fragment in self:
|
|
89
|
+
self._record(fragment)
|
|
90
|
+
|
|
91
|
+
def insert(self, index: int, fragment: str) -> None:
|
|
92
|
+
super().insert(index, fragment)
|
|
93
|
+
self._record_rewrite()
|
|
94
|
+
|
|
95
|
+
def __setitem__(self, index, fragments) -> None:
|
|
96
|
+
super().__setitem__(index, fragments)
|
|
97
|
+
self._record_rewrite()
|
|
98
|
+
|
|
99
|
+
def __iadd__(self, fragments):
|
|
100
|
+
self.extend(fragments)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __imul__(self, count):
|
|
104
|
+
super().__imul__(count)
|
|
105
|
+
self._record_rewrite()
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __delitem__(self, index) -> None:
|
|
109
|
+
super().__delitem__(index)
|
|
110
|
+
self._record_rewrite()
|
|
111
|
+
|
|
112
|
+
def clear(self) -> None:
|
|
113
|
+
super().clear()
|
|
114
|
+
self._record_rewrite()
|
|
115
|
+
|
|
116
|
+
def pop(self, index=-1):
|
|
117
|
+
fragment = super().pop(index)
|
|
118
|
+
self._record_rewrite()
|
|
119
|
+
return fragment
|
|
120
|
+
|
|
121
|
+
def remove(self, fragment) -> None:
|
|
122
|
+
super().remove(fragment)
|
|
123
|
+
self._record_rewrite()
|
|
124
|
+
|
|
125
|
+
def reverse(self) -> None:
|
|
126
|
+
super().reverse()
|
|
127
|
+
self._record_rewrite()
|
|
128
|
+
|
|
129
|
+
def sort(self, *, key=None, reverse=False) -> None:
|
|
130
|
+
try:
|
|
131
|
+
super().sort(key=key, reverse=reverse)
|
|
132
|
+
finally:
|
|
133
|
+
self._record_rewrite()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class LoweredParameter:
|
|
138
|
+
text: str
|
|
139
|
+
node: object
|
|
140
|
+
default: str | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class LoweredCall:
|
|
145
|
+
node: FuncCall
|
|
146
|
+
call: str
|
|
147
|
+
parameters: dict[str, LoweredParameter]
|
|
148
|
+
context: str
|
|
149
|
+
form: str | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def record_order_call(emitter, node, call, parameters, form=None) -> None:
|
|
153
|
+
"""Called only by a branch that lowers the corresponding order command."""
|
|
154
|
+
key = (id(node), getattr(emitter, "_current_instance_name", None))
|
|
155
|
+
emitter._order_shape_calls.setdefault(key, LoweredCall(
|
|
156
|
+
node, call, {name: LoweredParameter(*value) for name, value in parameters.items()},
|
|
157
|
+
"repeatable" if getattr(emitter, "_current_func_body", None) is not None else "straight", form,
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _literal(node, bindings):
|
|
162
|
+
if isinstance(node, (NumberLiteral, StringLiteral, BoolLiteral)):
|
|
163
|
+
return node.value
|
|
164
|
+
if isinstance(node, Identifier):
|
|
165
|
+
return bindings.get(node.name)
|
|
166
|
+
if isinstance(node, UnaryOp) and node.op in ("+", "-"):
|
|
167
|
+
value = _literal(node.operand, bindings)
|
|
168
|
+
if type(value) in (int, float):
|
|
169
|
+
return value if node.op == "+" else -value
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _finite(value) -> bool:
|
|
174
|
+
return type(value) in (int, float) and math.isfinite(value)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _never_na(node, bindings) -> bool:
|
|
178
|
+
if _finite(_literal(node, bindings)):
|
|
179
|
+
return True
|
|
180
|
+
if isinstance(node, Identifier):
|
|
181
|
+
return node.name in FINITE_BAR_VALUES and node.name not in bindings
|
|
182
|
+
if isinstance(node, UnaryOp) and node.op in ("+", "-"):
|
|
183
|
+
return _never_na(node.operand, bindings)
|
|
184
|
+
if isinstance(node, BinOp):
|
|
185
|
+
if node.op in ("+", "-"):
|
|
186
|
+
return _never_na(node.left, bindings) and _never_na(node.right, bindings)
|
|
187
|
+
if node.op == "*":
|
|
188
|
+
return ((_finite(_literal(node.left, bindings)) and _never_na(node.right, bindings))
|
|
189
|
+
or (_finite(_literal(node.right, bindings)) and _never_na(node.left, bindings)))
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parameter_class(name, parameter, bindings):
|
|
194
|
+
if parameter.text == parameter.default:
|
|
195
|
+
return "absent"
|
|
196
|
+
value = _literal(parameter.node, bindings)
|
|
197
|
+
if name in ("id", "from_entry"):
|
|
198
|
+
if parameter.node is None and parameter.text == '\"\"':
|
|
199
|
+
value = ""
|
|
200
|
+
if isinstance(value, str):
|
|
201
|
+
return ("global" if not value else "named") if name == "from_entry" else (
|
|
202
|
+
"literal" if value else "empty")
|
|
203
|
+
return "dynamic"
|
|
204
|
+
if name == "direction":
|
|
205
|
+
if parameter.text == "true":
|
|
206
|
+
return "long"
|
|
207
|
+
if parameter.text == "false":
|
|
208
|
+
return "short"
|
|
209
|
+
return "dynamic"
|
|
210
|
+
if name in NUMERIC_PARAMETERS:
|
|
211
|
+
if _finite(value):
|
|
212
|
+
return "literal"
|
|
213
|
+
return "never_na" if _never_na(parameter.node, bindings) else "maybe_na"
|
|
214
|
+
if name in ("comment", "oca_name"):
|
|
215
|
+
if value == "":
|
|
216
|
+
return "absent"
|
|
217
|
+
return "literal" if isinstance(value, str) else "dynamic"
|
|
218
|
+
if name in ("oca_type", "qty_type"):
|
|
219
|
+
if isinstance(parameter.node, MemberAccess):
|
|
220
|
+
value = ENUM_VALUES.get(_expression(parameter.node))
|
|
221
|
+
if _finite(value) and str(int(value)) == parameter.default and value == int(value):
|
|
222
|
+
return "absent"
|
|
223
|
+
return f"literal:{int(value)}" if _finite(value) and value == int(value) else "dynamic"
|
|
224
|
+
if name == "immediately":
|
|
225
|
+
return "literal:true" if value is True else "dynamic"
|
|
226
|
+
raise ValueError(f"unclassified lowered order parameter: {name}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _site_facts(ast, mutable_globals, recorded_nodes):
|
|
230
|
+
"""Source ordering, lexical shadows, and repeatability for authored call sites."""
|
|
231
|
+
bindings = {}
|
|
232
|
+
for node in ast.body:
|
|
233
|
+
if isinstance(node, VarDecl):
|
|
234
|
+
value = _literal(node.value, bindings)
|
|
235
|
+
bindings[node.name] = (value if not node.is_var and not node.is_varip
|
|
236
|
+
and node.name not in mutable_globals else None)
|
|
237
|
+
facts = {}
|
|
238
|
+
|
|
239
|
+
def block(statements, repeatable, visible, names=()):
|
|
240
|
+
local = {name: None for name in names}
|
|
241
|
+
scope = ChainMap(local, visible)
|
|
242
|
+
for statement in statements:
|
|
243
|
+
walk(statement, repeatable, scope)
|
|
244
|
+
if isinstance(statement, VarDecl):
|
|
245
|
+
local[statement.name] = None
|
|
246
|
+
elif isinstance(statement, TupleAssign):
|
|
247
|
+
local.update({name: None for name in statement.names})
|
|
248
|
+
|
|
249
|
+
def walk(node, repeatable, visible):
|
|
250
|
+
if id(node) in recorded_nodes:
|
|
251
|
+
facts[id(node)] = (len(facts), "repeatable" if repeatable else "straight", dict(visible))
|
|
252
|
+
if isinstance(node, (FuncDef, MethodDef)):
|
|
253
|
+
block(node.body, True, visible,
|
|
254
|
+
(name if isinstance(name, str) else name[0] for name in node.params))
|
|
255
|
+
elif isinstance(node, (ForStmt, ForInStmt, WhileStmt)):
|
|
256
|
+
for field in ("start", "end", "step", "iterable", "condition"):
|
|
257
|
+
expression = getattr(node, field, None)
|
|
258
|
+
if expression is not None:
|
|
259
|
+
walk(expression, True, visible)
|
|
260
|
+
names = ([node.var] if node.var else node.vars or []) if isinstance(node, (ForStmt, ForInStmt)) else ()
|
|
261
|
+
block(node.body, True, visible, names)
|
|
262
|
+
elif isinstance(node, IfStmt):
|
|
263
|
+
walk(node.condition, repeatable, visible)
|
|
264
|
+
block(node.body, repeatable, visible)
|
|
265
|
+
block(node.else_body, repeatable, visible)
|
|
266
|
+
elif isinstance(node, SwitchStmt):
|
|
267
|
+
if node.expr is not None:
|
|
268
|
+
walk(node.expr, repeatable, visible)
|
|
269
|
+
for condition, statements in node.cases:
|
|
270
|
+
if condition is not None:
|
|
271
|
+
walk(condition, repeatable, visible)
|
|
272
|
+
block(statements, repeatable, visible)
|
|
273
|
+
block(node.default_body, repeatable, visible)
|
|
274
|
+
else:
|
|
275
|
+
for child in reversed(list(syntax_children(node))):
|
|
276
|
+
walk(child, repeatable, visible)
|
|
277
|
+
|
|
278
|
+
for statement in ast.body:
|
|
279
|
+
walk(statement, False, bindings)
|
|
280
|
+
return facts
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def settings_echo(constructor):
|
|
284
|
+
settings = dict(SETTING_DEFAULTS)
|
|
285
|
+
pooc = False
|
|
286
|
+
for statement in constructor:
|
|
287
|
+
match = re.fullmatch(r" cfg\.(\w+) = (.*);", statement)
|
|
288
|
+
if match is None:
|
|
289
|
+
continue
|
|
290
|
+
name, text = match.groups()
|
|
291
|
+
if name == "process_orders_on_close":
|
|
292
|
+
pooc = text == "true"
|
|
293
|
+
elif name == "close_entries_rule_any":
|
|
294
|
+
settings["close_entries_rule"] = "ANY" if text == "true" else "FIFO"
|
|
295
|
+
elif name in settings:
|
|
296
|
+
if text.startswith("static_cast<int>("):
|
|
297
|
+
settings[name] = SETTING_ENUMS[text[len("static_cast<int>("):-1]]
|
|
298
|
+
else:
|
|
299
|
+
value = float(text)
|
|
300
|
+
settings[name] = (int(value) if name in ("pyramiding", "slippage") else value) if math.isfinite(value) else None
|
|
301
|
+
return settings, pooc
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def order_shapes_document(emitter) -> str:
|
|
305
|
+
recorded_nodes = {id(record.node) for record in emitter._order_shape_calls.values()}
|
|
306
|
+
facts = _site_facts(emitter.ctx.ast, emitter.ctx.global_mutable_infos, recorded_nodes)
|
|
307
|
+
records = sorted(emitter._order_shape_calls.values(), key=lambda record: (
|
|
308
|
+
record.node.loc.line if record.node.loc else 0,
|
|
309
|
+
record.node.loc.col if record.node.loc else 0,
|
|
310
|
+
facts.get(id(record.node), (len(facts),))[0],
|
|
311
|
+
))
|
|
312
|
+
calls = []
|
|
313
|
+
ids = []
|
|
314
|
+
for site, record in enumerate(records):
|
|
315
|
+
_order, context, bindings = facts.get(id(record.node), (site, record.context, {}))
|
|
316
|
+
descriptor = {"site": site, "call": record.call, "context": context}
|
|
317
|
+
if record.form is not None:
|
|
318
|
+
descriptor["form"] = record.form
|
|
319
|
+
descriptor.update({name: parameter_class(name, parameter, bindings)
|
|
320
|
+
for name, parameter in record.parameters.items()})
|
|
321
|
+
calls.append(descriptor)
|
|
322
|
+
ids.append({name: _literal(parameter.node, bindings) if parameter.node is not None else ""
|
|
323
|
+
for name, parameter in record.parameters.items() if name in ("id", "from_entry")})
|
|
324
|
+
entries = [(site, ids[site].get("id"), call.get("direction"))
|
|
325
|
+
for site, call in enumerate(calls) if call["call"] in ("entry", "order")
|
|
326
|
+
and call["id"] in ("literal", "empty")]
|
|
327
|
+
long_ids = {name for _site, name, direction in entries if direction == "long"}
|
|
328
|
+
short_ids = {name for _site, name, direction in entries if direction == "short"}
|
|
329
|
+
for site, call in enumerate(calls):
|
|
330
|
+
if call["call"] not in ("exit", "close", "cancel"):
|
|
331
|
+
continue
|
|
332
|
+
target = ids[site].get("from_entry" if call["call"] == "exit" else "id")
|
|
333
|
+
global_target = target == "" and call["call"] in ("exit", "close")
|
|
334
|
+
matching = [(entry_site, direction) for entry_site, name, direction in entries
|
|
335
|
+
if global_target or target is not None and target == name]
|
|
336
|
+
directions = {direction for _entry_site, direction in matching}
|
|
337
|
+
call["target"] = ("both" if "dynamic" in directions or directions >= {"long", "short"}
|
|
338
|
+
else "long" if "long" in directions else "short" if "short" in directions
|
|
339
|
+
else "dangling")
|
|
340
|
+
if call["call"] == "exit":
|
|
341
|
+
before = any(entry_site > site for entry_site, _direction in matching)
|
|
342
|
+
after = any(entry_site < site for entry_site, _direction in matching)
|
|
343
|
+
call["order"] = "mixed" if before and after or not matching else "before" if before else "after"
|
|
344
|
+
counts = Counter(name for _site, name, _direction in entries)
|
|
345
|
+
settings, pooc = settings_echo(emitter._order_shape_constructor)
|
|
346
|
+
unmodeled = {_expression(node.callee) for node, _depth in iter_ast_nodes(emitter.ctx.ast)
|
|
347
|
+
if isinstance(node, FuncCall) and (_expression(node.callee) or "").startswith("strategy.")
|
|
348
|
+
and _expression(node.callee) not in READ_ONLY_STRATEGY_CALLS
|
|
349
|
+
and id(node) not in recorded_nodes}
|
|
350
|
+
return json.dumps({
|
|
351
|
+
"version": 1, "process_orders_on_close": pooc, "calls": calls,
|
|
352
|
+
"entry_ids": {"long": len(long_ids), "short": len(short_ids),
|
|
353
|
+
"shared": len(long_ids & short_ids), "multi_site": sum(count > 1 for count in counts.values())},
|
|
354
|
+
"host_reads": sorted(emitter._order_shape_host_reads), "settings": settings,
|
|
355
|
+
"unmodeled": sorted(unmodeled),
|
|
356
|
+
}, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
@@ -194,13 +194,19 @@ class SecurityEmitter:
|
|
|
194
194
|
chart timeframe — its evaluator result is never read. Any other
|
|
195
195
|
timeframe registration cannot compute is refused: it used to register
|
|
196
196
|
the chart timeframe, silently.
|
|
197
|
+
|
|
198
|
+
Every timeframe string resolved from the script -- the literal, a
|
|
199
|
+
constant, a helper parameter's argument, a ternary or switch arm --
|
|
200
|
+
is a Pine timeframe (``_refuse_invalid_security_tf``).
|
|
197
201
|
"""
|
|
198
202
|
if isinstance(tf_node, StringLiteral):
|
|
203
|
+
self._refuse_invalid_security_tf(tf_node.value, tf_node)
|
|
199
204
|
return tf_node.value, None
|
|
200
205
|
if isinstance(tf_node, SwitchStmt):
|
|
201
206
|
# Keep diagnostics from the registration-time switch renderer
|
|
202
207
|
# visible; the broad expression fallback below intentionally
|
|
203
208
|
# catches ordinary unresolved expressions.
|
|
209
|
+
self._check_security_tf_arms(tf_node, set())
|
|
204
210
|
return None, self._security_tf_runtime_expr(tf_node)
|
|
205
211
|
if isinstance(tf_node, Identifier):
|
|
206
212
|
name = tf_node.name
|
|
@@ -209,6 +215,7 @@ class SecurityEmitter:
|
|
|
209
215
|
if (name in self._known_vars and name not in self._input_backed_vars
|
|
210
216
|
and not self._known_var_is_lexically_shadowed(name)
|
|
211
217
|
and isinstance(self._known_vars[name], str)):
|
|
218
|
+
self._refuse_invalid_security_tf(self._known_vars[name], tf_node)
|
|
212
219
|
return self._known_vars[name], None
|
|
213
220
|
if (name in self._input_backed_vars
|
|
214
221
|
and name in self._input_var_to_call
|
|
@@ -216,6 +223,7 @@ class SecurityEmitter:
|
|
|
216
223
|
return None, self._visit_expr(self._input_var_to_call[name])
|
|
217
224
|
global_expr_map = getattr(self.ctx, "global_expr_map", {}) or {}
|
|
218
225
|
if name in global_expr_map:
|
|
226
|
+
self._check_security_tf_arms(global_expr_map[name], {name})
|
|
219
227
|
expanded = self._security_tf_runtime_expr(
|
|
220
228
|
global_expr_map[name], resolving={name}
|
|
221
229
|
)
|
|
@@ -238,6 +246,7 @@ class SecurityEmitter:
|
|
|
238
246
|
self._security_tf_unresolved(tf_node, f"timeframe '{name}'")
|
|
239
247
|
# any other expression — visit if it resolves at class scope
|
|
240
248
|
try:
|
|
249
|
+
self._check_security_tf_arms(tf_node, set())
|
|
241
250
|
expanded = self._security_tf_runtime_expr(tf_node)
|
|
242
251
|
return None, expanded if expanded is not None else self._visit_expr(tf_node)
|
|
243
252
|
except CompileError:
|
|
@@ -245,6 +254,45 @@ class SecurityEmitter:
|
|
|
245
254
|
except Exception:
|
|
246
255
|
self._security_tf_unresolved(tf_node, "timeframe expression")
|
|
247
256
|
|
|
257
|
+
def _refuse_invalid_security_tf(self, value: str, node) -> None:
|
|
258
|
+
"""Refuse a timeframe string resolved from the script that is not a
|
|
259
|
+
Pine timeframe, with the support checker's diagnostic for the same
|
|
260
|
+
string written in the call (``invalid_tf_literal``): it reaches the
|
|
261
|
+
C++ registration. An empty string registers the chart's timeframe."""
|
|
262
|
+
if not value:
|
|
263
|
+
return
|
|
264
|
+
from ..support_checker import invalid_tf_literal
|
|
265
|
+
fn_label = ("request.security_lower_tf" if getattr(self, "_security_tf_lower", False)
|
|
266
|
+
else "request.security")
|
|
267
|
+
refusal = invalid_tf_literal(value, fn_label)
|
|
268
|
+
if refusal is not None:
|
|
269
|
+
message, hint = refusal
|
|
270
|
+
self._codegen_error(node, message, hint=hint)
|
|
271
|
+
|
|
272
|
+
def _check_security_tf_arms(self, node, resolving: set[str]) -> None:
|
|
273
|
+
"""Refuse an invalid timeframe string a registration-time timeframe
|
|
274
|
+
expression can select: the expression itself, a ternary or switch
|
|
275
|
+
arm, read through the constants and globals it names."""
|
|
276
|
+
def check(value) -> None:
|
|
277
|
+
if isinstance(value, StringLiteral):
|
|
278
|
+
self._refuse_invalid_security_tf(value.value, value)
|
|
279
|
+
elif isinstance(value, Ternary):
|
|
280
|
+
check(value.true_val)
|
|
281
|
+
check(value.false_val)
|
|
282
|
+
elif isinstance(value, SwitchStmt):
|
|
283
|
+
arms = [body for _case, body in value.cases]
|
|
284
|
+
for body in arms + ([value.default_body] if value.default_body else []):
|
|
285
|
+
if len(body) == 1 and isinstance(body[0], ExprStmt):
|
|
286
|
+
check(self._substitute_tf_input_reads(body[0].expr, resolving))
|
|
287
|
+
|
|
288
|
+
check(node if isinstance(node, SwitchStmt)
|
|
289
|
+
else self._substitute_tf_input_reads(node, resolving))
|
|
290
|
+
|
|
291
|
+
def _security_tf_literal(self, tf: str) -> str:
|
|
292
|
+
"""A timeframe string as the C++ string literal registration and the
|
|
293
|
+
evaluator's ``timeframe.*`` reads spell it."""
|
|
294
|
+
return f'"{self._cpp_string_escape(tf)}"'
|
|
295
|
+
|
|
248
296
|
def _security_tf_unresolved(self, tf_node, what: str) -> None:
|
|
249
297
|
"""Refuse a request.security timeframe registration cannot compute."""
|
|
250
298
|
self._codegen_error(
|
|
@@ -1981,8 +2029,9 @@ class SecurityEmitter:
|
|
|
1981
2029
|
parts = request.annotations[RECORDED_KEY_ANNOTATION]
|
|
1982
2030
|
tail = (f"|{parts['field']}|{parts['period']}|gaps_{parts['gaps']}"
|
|
1983
2031
|
f"|lookahead_{parts['lookahead']}")
|
|
1984
|
-
|
|
1985
|
-
|
|
2032
|
+
fn = self._cpp_string_escape(parts["fn"])
|
|
2033
|
+
return (f'(std::string("{fn}|") + {self._visit_expr(request.args[0])} + '
|
|
2034
|
+
f'std::string("{self._cpp_string_escape(tail)}"))')
|
|
1986
2035
|
|
|
1987
2036
|
def _recorded_sites(self) -> dict[int, int]:
|
|
1988
2037
|
"""Each recorded request's index N: ``_pf_recorded`` sets its
|
|
@@ -2709,7 +2758,7 @@ class SecurityEmitter:
|
|
|
2709
2758
|
"""C++ expression for the timeframe of a request.security evaluator."""
|
|
2710
2759
|
info = self._security_eval_info[sec_id]
|
|
2711
2760
|
if info.get("tf"):
|
|
2712
|
-
return
|
|
2761
|
+
return self._security_tf_literal(info["tf"])
|
|
2713
2762
|
if info.get("tf_expr"):
|
|
2714
2763
|
return info["tf_expr"]
|
|
2715
2764
|
return "input_tf_"
|
|
@@ -2775,7 +2824,7 @@ class SecurityEmitter:
|
|
|
2775
2824
|
if series_name in self._security_string_series
|
|
2776
2825
|
else "_security_helper_series_"
|
|
2777
2826
|
)
|
|
2778
|
-
return f'{store}["{series_name}"]'
|
|
2827
|
+
return f'{store}["{self._cpp_string_escape(series_name)}"]'
|
|
2779
2828
|
|
|
2780
2829
|
def _security_helper_var_state_type(self, stmt: VarDecl) -> str:
|
|
2781
2830
|
"""The type family of a helper ``var`` whose declaration reads
|
|
@@ -6377,7 +6426,7 @@ class SecurityEmitter:
|
|
|
6377
6426
|
if column is not None and self._security_foreign(sec_id):
|
|
6378
6427
|
# request.footprint(...) of another symbol: the delta its feed
|
|
6379
6428
|
# records for the requested bar (the value its delta() reads).
|
|
6380
|
-
return f'_pf_symbol_column({sec_id}, "{column}")'
|
|
6429
|
+
return f'_pf_symbol_column({sec_id}, "{self._cpp_string_escape(column)}")'
|
|
6381
6430
|
|
|
6382
6431
|
if resolving is None:
|
|
6383
6432
|
resolving = set()
|
|
@@ -545,6 +545,18 @@ ORDER_DIRECTION_MAP = {
|
|
|
545
545
|
"descending": 'std::string("descending")',
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
NAME_ECHO_STRING_MEMBERS = {
|
|
549
|
+
"currency": frozenset({
|
|
550
|
+
"AED", "ARS", "AUD", "BDT", "BHD", "BRL", "BTC", "CAD", "CHF", "CLP",
|
|
551
|
+
"CNY", "COP", "CZK", "DKK", "EGP", "ETH", "EUR", "GBP", "HKD", "HUF",
|
|
552
|
+
"IDR", "ILS", "INR", "ISK", "JPY", "KES", "KRW", "KWD", "LKR", "MAD",
|
|
553
|
+
"MXN", "MYR", "NGN", "NOK", "NONE", "NZD", "PEN", "PHP", "PKR", "PLN",
|
|
554
|
+
"QAR", "RON", "RSD", "RUB", "SAR", "SEK", "SGD", "THB", "TND", "TRY",
|
|
555
|
+
"TWD", "USD", "USDT", "VES", "VND", "ZAR",
|
|
556
|
+
}),
|
|
557
|
+
"format": frozenset({"inherit", "price", "volume", "percent", "mintick"}),
|
|
558
|
+
}
|
|
559
|
+
|
|
548
560
|
|
|
549
561
|
# ---------------------------------------------------------------------------
|
|
550
562
|
# Array / Map / Matrix method dispatch
|
|
@@ -432,6 +432,7 @@ class CallVisitor:
|
|
|
432
432
|
|
|
433
433
|
close = "true" if func_name == "time_close" else "false"
|
|
434
434
|
empty = 'std::string("")'
|
|
435
|
+
self._order_shape_host_reads.add("pine_time_offset")
|
|
435
436
|
return (
|
|
436
437
|
f"pine_time_offset(current_bar_.timestamp, {offset('bars_back')}, "
|
|
437
438
|
f"{text('timeframe', 'script_tf_')}, {text('session', empty)}, "
|
|
@@ -3166,6 +3167,7 @@ class CallVisitor:
|
|
|
3166
3167
|
return f"{token}ULL"
|
|
3167
3168
|
|
|
3168
3169
|
def _visit_strategy_call(self, func_name: str, node: FuncCall) -> str:
|
|
3170
|
+
from .order_shapes import record_order_call
|
|
3169
3171
|
if func_name in ("convert_to_account", "convert_to_symbol"):
|
|
3170
3172
|
p = self._resolve_func_args(node, f"strategy.{func_name}")
|
|
3171
3173
|
v = self._visit_expr(p.get("value")) if p.get("value") is not None else "0.0"
|
|
@@ -3200,6 +3202,16 @@ class CallVisitor:
|
|
|
3200
3202
|
if stop is not None or limit is not None or qty is not None or oca_name is not None or oca_type is not None or qty_type is not None:
|
|
3201
3203
|
limit_val = self._visit_expr(limit) if limit else "na<double>()"
|
|
3202
3204
|
stop_val = self._visit_expr(stop) if stop else "na<double>()"
|
|
3205
|
+
record_order_call(self, node, "entry", {
|
|
3206
|
+
"id": (entry_id, p.get("id")), "direction": (direction, direction_node),
|
|
3207
|
+
"limit": (limit_val, limit, "na<double>()"),
|
|
3208
|
+
"stop": (stop_val, stop, "na<double>()"),
|
|
3209
|
+
"qty": (qty_val, qty, "na<double>()"),
|
|
3210
|
+
"comment": (comment_val, comment, '""'),
|
|
3211
|
+
"oca_name": (oca_name_val, oca_name, '""'),
|
|
3212
|
+
"oca_type": (oca_type_val, oca_type, "0"),
|
|
3213
|
+
"qty_type": (qty_type_val, qty_type, "-1"),
|
|
3214
|
+
})
|
|
3203
3215
|
# pineforge-engine v0.2 dropped the vestigial `market_price`
|
|
3204
3216
|
# third positional from `BacktestEngine::strategy_entry`
|
|
3205
3217
|
# (the runtime never read it; fill price always came from
|
|
@@ -3207,6 +3219,15 @@ class CallVisitor:
|
|
|
3207
3219
|
# matches the new signature: (id, direction, limit, stop,
|
|
3208
3220
|
# qty, comment, oca_name, oca_type, qty_type).
|
|
3209
3221
|
return f"strategy_entry({entry_id}, {direction}, {limit_val}, {stop_val}, {qty_val}, {comment_val}, {oca_name_val}, {oca_type_val}, {qty_type_val})"
|
|
3222
|
+
record_order_call(self, node, "entry", {
|
|
3223
|
+
"id": (entry_id, p.get("id")), "direction": (direction, direction_node),
|
|
3224
|
+
"limit": ("na<double>()", None, "na<double>()"),
|
|
3225
|
+
"stop": ("na<double>()", None, "na<double>()"),
|
|
3226
|
+
"qty": ("na<double>()", None, "na<double>()"),
|
|
3227
|
+
"comment": (comment_val, comment, '""'),
|
|
3228
|
+
"oca_name": ('""', None, '""'), "oca_type": ("0", None, "0"),
|
|
3229
|
+
"qty_type": ("-1", None, "-1"),
|
|
3230
|
+
})
|
|
3210
3231
|
return f"strategy_entry({entry_id}, {direction}, na<double>(), na<double>(), na<double>(), {comment_val})"
|
|
3211
3232
|
|
|
3212
3233
|
if func_name == "close":
|
|
@@ -3223,6 +3244,12 @@ class CallVisitor:
|
|
|
3223
3244
|
if immediately_node is not None else "false"
|
|
3224
3245
|
)
|
|
3225
3246
|
callsite = self._strategy_close_callsite_token(node)
|
|
3247
|
+
record_order_call(self, node, "close", {
|
|
3248
|
+
"id": (close_id, p.get("id")), "comment": (comment, p.get("comment"), '""'),
|
|
3249
|
+
"qty": (qty, p.get("qty"), "na<double>()"),
|
|
3250
|
+
"qty_percent": (qty_pct, p.get("qty_percent"), "na<double>()"),
|
|
3251
|
+
"immediately": (immediately, immediately_node, "false"),
|
|
3252
|
+
})
|
|
3226
3253
|
return f"strategy_close({close_id}, {comment}, {qty}, {qty_pct}, {immediately}, {callsite})"
|
|
3227
3254
|
|
|
3228
3255
|
if func_name == "close_all":
|
|
@@ -3235,6 +3262,12 @@ class CallVisitor:
|
|
|
3235
3262
|
)
|
|
3236
3263
|
if immediately_node is not None else "false"
|
|
3237
3264
|
)
|
|
3265
|
+
record_order_call(self, node, "close_all", {
|
|
3266
|
+
"comment": (comment, p.get("comment"), '""'),
|
|
3267
|
+
"qty": ("na<double>()", None, "na<double>()"),
|
|
3268
|
+
"qty_percent": ("na<double>()", None, "na<double>()"),
|
|
3269
|
+
"immediately": (immediately, immediately_node, "false"),
|
|
3270
|
+
})
|
|
3238
3271
|
# The engine's ID-less strategy_close path closes the complete
|
|
3239
3272
|
# position. Reuse it so close_all preserves the Pine order comment
|
|
3240
3273
|
# and same-tick fill flag instead of silently discarding both.
|
|
@@ -3278,18 +3311,38 @@ class CallVisitor:
|
|
|
3278
3311
|
if loss_n and not stop_n:
|
|
3279
3312
|
loss_ticks = self._visit_expr(loss_n)
|
|
3280
3313
|
|
|
3314
|
+
record_order_call(self, node, "exit", {
|
|
3315
|
+
"id": (exit_id, p.get("id")), "from_entry": (from_id, p.get("from_entry")),
|
|
3316
|
+
"limit": (limit_val, limit_n, "na<double>()"),
|
|
3317
|
+
"stop": (stop_val, stop_n, "na<double>()"),
|
|
3318
|
+
"trail_points": (trail_pts, trail_pts_n, "na<double>()"),
|
|
3319
|
+
"trail_offset": (trail_off, trail_off_n, "na<double>()"),
|
|
3320
|
+
"trail_price": (trail_pr, trail_pr_n, "na<double>()"),
|
|
3321
|
+
"qty_percent": (qty_pct, qty_pct_n, "100.0"),
|
|
3322
|
+
"comment": (comment, comment_n, '""'),
|
|
3323
|
+
"qty": (qty_val, qty_n, "na<double>()"),
|
|
3324
|
+
"oca_name": (oca_val, oca_name_n, '""'),
|
|
3325
|
+
"profit_ticks": (profit_ticks, profit_n if not limit_n else None, "na<double>()"),
|
|
3326
|
+
"loss_ticks": (loss_ticks, loss_n if not stop_n else None, "na<double>()"),
|
|
3327
|
+
}, form="levels")
|
|
3281
3328
|
return (f"strategy_exit({exit_id}, {from_id}, {limit_val}, {stop_val}, "
|
|
3282
3329
|
f"{trail_pts}, {trail_off}, {trail_pr}, {qty_pct}, {comment}, "
|
|
3283
3330
|
f"{qty_val}, {oca_val}, {profit_ticks}, {loss_ticks})")
|
|
3284
3331
|
comment = self._visit_expr(comment_n) if comment_n is not None else '""'
|
|
3332
|
+
record_order_call(self, node, "exit", {
|
|
3333
|
+
"id": (exit_id, p.get("id")), "from_entry": (from_id, p.get("from_entry")),
|
|
3334
|
+
"comment": (comment, comment_n, '""'),
|
|
3335
|
+
}, form="cancel_bracket")
|
|
3285
3336
|
return f"strategy_exit_cancel_bracket({exit_id}, {from_id}, {comment})"
|
|
3286
3337
|
|
|
3287
3338
|
if func_name == "cancel":
|
|
3288
3339
|
p = self._resolve_func_args(node, "strategy.close") # same shape: id first
|
|
3289
3340
|
cancel_id = self._visit_expr(p.get("id")) if "id" in p else '""'
|
|
3341
|
+
record_order_call(self, node, "cancel", {"id": (cancel_id, p.get("id"))})
|
|
3290
3342
|
return f"strategy_cancel({cancel_id})"
|
|
3291
3343
|
|
|
3292
3344
|
if func_name == "cancel_all":
|
|
3345
|
+
record_order_call(self, node, "cancel_all", {})
|
|
3293
3346
|
return "strategy_cancel_all()"
|
|
3294
3347
|
|
|
3295
3348
|
if func_name == "order":
|
|
@@ -3307,6 +3360,14 @@ class CallVisitor:
|
|
|
3307
3360
|
stop_arg = self._visit_expr(p.get("stop")) if "stop" in p else "na<double>()"
|
|
3308
3361
|
oca_name = self._visit_expr(p.get("oca_name")) if "oca_name" in p else '""'
|
|
3309
3362
|
oca_type = self._visit_expr(p.get("oca_type")) if "oca_type" in p else "0"
|
|
3363
|
+
record_order_call(self, node, "order", {
|
|
3364
|
+
"id": (order_id, p.get("id")), "direction": (direction, direction_node),
|
|
3365
|
+
"qty": (qty, p.get("qty"), "0"),
|
|
3366
|
+
"limit": (limit_arg, p.get("limit"), "na<double>()"),
|
|
3367
|
+
"stop": (stop_arg, p.get("stop"), "na<double>()"),
|
|
3368
|
+
"oca_name": (oca_name, p.get("oca_name"), '""'),
|
|
3369
|
+
"oca_type": (oca_type, p.get("oca_type"), "0"),
|
|
3370
|
+
})
|
|
3310
3371
|
return f"strategy_order({order_id}, {direction}, {qty}, {limit_arg}, {stop_arg}, {oca_name}, {oca_type})"
|
|
3311
3372
|
|
|
3312
3373
|
if func_name == "risk":
|