@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.
@@ -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
- return (f'(std::string("{parts["fn"]}|") + {self._visit_expr(request.args[0])} + '
1985
- f'std::string("{tail}"))')
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 f'"{info["tf"]}"'
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":