@pineforge/codegen-pyodide 1.2.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 CHANGED
@@ -1,6 +1,14 @@
1
- PineForge Source License 1.1
1
+ PineForge Source License 1.2
2
2
  ============================
3
3
 
4
+ Version 1.2, 2026-10-05. Compared with 1.1, two places change. In the
5
+ Personal Trading section, the last bullet now says that an account a
6
+ proprietary-trading firm or funded-trader program provides or allocates,
7
+ including a challenge, evaluation or simulated account, is not the
8
+ person's own account or capital. In the Commercial Use section, the
9
+ definition of investment capital now covers the capital in such an
10
+ account, real or simulated.
11
+
4
12
  Copyright 2025-2026 pineforge, LLC
5
13
 
6
14
  Required Notice: Copyright 2025-2026 pineforge, LLC (https://github.com/pineforge-4pass/pineforge-codegen-oss)
@@ -134,7 +142,12 @@ For Personal Trading:
134
142
  program or anyone else outside the person's household provides or
135
143
  allocates to the person to trade, and that the provider still owns or
136
144
  whose losses the provider bears, is not the person's own capital, even
137
- if the person shares in the profits.
145
+ if the person shares in the profits. An account that a
146
+ proprietary-trading firm or a funded-trader program provides or
147
+ allocates to the person to trade, including a challenge, evaluation or
148
+ simulated account, whether the capital in it is real or simulated, is
149
+ likewise not the person's own account and is not funded by the
150
+ person's own capital.
138
151
 
139
152
 
140
153
  ## Commercial Use
@@ -149,7 +162,10 @@ in each case whoever the capital belongs to and whether or not for a fee.
149
162
  trading, including capital belonging to any other person or entity, such as a
150
163
  friend or relative, clients or investors, an endowment, a pension or
151
164
  retirement fund, a sovereign or other public fund, or the treasury or reserves
152
- of a foundation or other organization.
165
+ of a foundation or other organization. The capital in an account that a
166
+ proprietary-trading firm or a funded-trader program provides or allocates
167
+ to a person or entity to trade, including a challenge, evaluation or
168
+ simulated account, is investment capital whether it is real or simulated.
153
169
 
154
170
  **Commercial Use** means any use of the software that is not a permitted
155
171
  purpose, other than distributing copies under the Distribution License
package/README.md CHANGED
@@ -19,7 +19,8 @@ Stable releases are on the `latest` dist-tag. A prerelease (for example
19
19
  - `pineforge_codegen/diagnostics_catalog.json` — the diagnostics catalog (since 1.2.0), also
20
20
  importable as `@pineforge/codegen-pyodide/diagnostics_catalog.json`; the repository's
21
21
  `docs/PUBLIC_CONTRACT.md` describes it.
22
- - `LICENSE` — the PineForge Source License 1.1 (since 1.2.0), which `package.json` names.
22
+ - `LICENSE` — the PineForge Source License (since 1.2.0), which `package.json` names:
23
+ version 1.1 in release 1.2.0, version 1.2 from release 1.3.0 on.
23
24
 
24
25
  ## Publishing (maintainers)
25
26
  A `v*` tag push, which the release workflow makes, publishes through npm OIDC Trusted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineforge/codegen-pyodide",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Gate-validated Pyodide payload for the PineScript v6 -> C++ transpiler: archive (run in Pyodide), unpacked source, introspected tables, and release metadata.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -240,6 +240,9 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
240
240
  def __init__(self, ctx: AnalyzerContext,
241
241
  budget: TimeBudget | None = None) -> None:
242
242
  self.ctx = ctx
243
+ self._order_shape_calls = {}
244
+ self._order_shape_host_reads: set[str] = set()
245
+ self._order_shape_constructor: list[str] = []
243
246
  self._budget = budget
244
247
  self._budget_visit_count = 0
245
248
  self._initialise_safe_names(ctx.ast)
@@ -4490,7 +4493,8 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
4490
4493
  self._prepare_lazy_source_clock_sites()
4491
4494
  self.block_locals_needing_names = self._block_locals_needing_names()
4492
4495
 
4493
- lines: list[str] = []
4496
+ from .order_shapes import HostReadLines
4497
+ lines: list[str] = HostReadLines(self._order_shape_host_reads)
4494
4498
 
4495
4499
  # Series<T> ctor-arg suffix from any max_bars_back directive (empty when
4496
4500
  # absent, so directive-free output is byte-identical to before).
@@ -5450,7 +5454,12 @@ class CodeGen(CallVisitor, ExprVisitor, StmtVisitor, TopLevelEmitter, SecurityEm
5450
5454
  if self._uses_session_market:
5451
5455
  lines.insert(_session_market_member_at, SESSION_MARKET_MEMBER)
5452
5456
  lines.insert(_session_market_at, SESSION_MARKET_CPP)
5457
+ self._capabilities_export_at += 2
5453
5458
 
5459
+ from .capabilities import emit_capabilities_exports
5460
+ exports: list[str] = []
5461
+ emit_capabilities_exports(self, exports)
5462
+ lines[self._capabilities_export_at:self._capabilities_export_at] = exports
5454
5463
  cpp = "\n".join(lines)
5455
5464
  self._settle_session_reads(cpp)
5456
5465
  return cpp
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  import math
5
5
 
6
- from ..ast_nodes import BoolLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, StrategyDecl, StringLiteral, UnaryOp, VarDecl
6
+ from ..ast_nodes import BoolLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, StrategyDecl, StringLiteral, Subscript, UnaryOp, VarDecl
7
7
  from ..external_requests import CAPABILITY_UNPINNED_ANNOTATION
8
8
  from ..limits import iter_ast_nodes
9
9
 
@@ -34,6 +34,28 @@ DECLARATION_DEFAULTS = {
34
34
  "calc_on_every_history_tick": False,
35
35
  }
36
36
 
37
+ MODELED_ORDER_ARGUMENTS = {
38
+ "strategy.entry": frozenset(("id", "direction", "limit", "stop")),
39
+ "strategy.exit": frozenset(("id", "from_entry", "limit", "stop")),
40
+ "strategy.close": frozenset(("id", "immediately")),
41
+ "strategy.close_all": frozenset(("immediately",)),
42
+ }
43
+ READ_ONLY_STRATEGY_CALLS = frozenset(
44
+ f"strategy.{namespace}.{accessor}"
45
+ for namespace in ("opentrades", "closedtrades")
46
+ for accessor in ("entry_id", "entry_price", "entry_time", "entry_bar_index", "size",
47
+ "profit", "profit_percent", "commission", "max_runup", "max_runup_percent",
48
+ "max_drawdown", "max_drawdown_percent", "exit_id", "exit_price",
49
+ "exit_time", "exit_bar_index", "entry_comment", "exit_comment")
50
+ ) | frozenset(("strategy.convert_to_account", "strategy.convert_to_symbol"))
51
+ ORDER_SETTINGS = frozenset(("pyramiding", "default_qty_type", "default_qty_value", "initial_capital",
52
+ "slippage", "commission_type", "commission_value", "close_entries_rule",
53
+ "margin_long", "margin_short", "risk_free_rate"))
54
+ PROVEN_BRACKET_SETTINGS = {"default_qty_type": "strategy.fixed", "default_qty_value": 1,
55
+ "commission_type": "strategy.commission.percent", "commission_value": 0}
56
+ PROVEN_SLIPPED_SETTINGS = {"default_qty_type": "strategy.percent_of_equity",
57
+ "default_qty_value": 100, "slippage": 15}
58
+
37
59
 
38
60
  def _expression(node):
39
61
  if isinstance(node, (StringLiteral, NumberLiteral, BoolLiteral)):
@@ -44,6 +66,8 @@ def _expression(node):
44
66
  return f"{_expression(node.object)}.{node.member}"
45
67
  if isinstance(node, FuncCall):
46
68
  return f"{_expression(node.callee)}({','.join(str(_expression(arg)) for arg in node.args)})"
69
+ if isinstance(node, Subscript):
70
+ return f"{_expression(node.object)}[{_expression(node.index)}]"
47
71
  return None
48
72
 
49
73
 
@@ -61,13 +85,57 @@ def _literal(node):
61
85
  return _expression(node), False
62
86
 
63
87
 
64
- def capabilities_document(emitter) -> str:
88
+ def _order_arguments(node):
89
+ name = _expression(node.callee)
90
+ positional = {
91
+ "strategy.entry": ("id", "direction", "qty", "limit", "stop", "oca_name", "oca_type", "comment", "alert_message", "disable_alert"),
92
+ "strategy.exit": ("id", "from_entry", "qty", "qty_percent", "profit", "limit", "loss", "stop", "trail_price", "trail_points", "trail_offset", "oca_name", "comment", "alert_message", "alert_profit", "alert_loss", "alert_trailing", "disable_alert"),
93
+ "strategy.close": ("id", "comment", "qty", "qty_percent", "alert_message", "immediately", "disable_alert"),
94
+ "strategy.close_all": ("comment", "alert_message", "immediately", "disable_alert"),
95
+ }.get(name, ())
96
+ arguments = {positional[index] if index < len(positional) else f"positional_{index}": argument
97
+ for index, argument in enumerate(node.args)}
98
+ if arguments.keys() & node.kwargs.keys():
99
+ arguments["duplicate_argument"] = None
100
+ arguments.update(node.kwargs)
101
+ return arguments
102
+
103
+
104
+ def _order_shape(node, short_ids):
105
+ name = _expression(node.callee)
106
+ arguments = _order_arguments(node)
107
+ if name not in MODELED_ORDER_ARGUMENTS or not set(arguments) <= MODELED_ORDER_ARGUMENTS[name]:
108
+ return name + (" (unproven exit terms)" if name == "strategy.exit" else " (unproven order shape)")
109
+ if name != "strategy.close_all" and not isinstance(arguments.get("id"), StringLiteral):
110
+ return name + " (unproven order id)"
111
+ if name == "strategy.entry" and _expression(arguments.get("direction")) in ("strategy.long", "strategy.short"):
112
+ priced = {key for key in ("limit", "stop") if key in arguments}
113
+ if len(priced) < 2 and not (set(arguments) & {"oca_name", "oca_type"}):
114
+ return "entry:" + (next(iter(priced)) if priced else "market")
115
+ if name == "strategy.exit":
116
+ if (set(arguments) & {"profit", "loss", "trail_price", "trail_points", "trail_offset", "qty", "qty_percent"}):
117
+ return name + " (unproven exit terms)"
118
+ if ("limit" in arguments and "stop" in arguments
119
+ and isinstance(arguments.get("from_entry"), StringLiteral)
120
+ and _expression(arguments["from_entry"]) in short_ids):
121
+ return "exit:short_bracket"
122
+ if name in ("strategy.close", "strategy.close_all"):
123
+ immediate = arguments.get("immediately")
124
+ if immediate is None or isinstance(immediate, BoolLiteral) and not immediate.value:
125
+ if not (set(arguments) & {"qty", "qty_percent"}):
126
+ return "close:market"
127
+ return name + " (unproven order shape)"
128
+
129
+
130
+ def capabilities_documents(emitter) -> tuple[str, str]:
65
131
  declarations = dict(DECLARATION_DEFAULTS)
66
132
  unresolved = []
67
133
  requests = []
68
134
  lower_symbols = {}
69
135
  intrabar = False
70
136
  recorded = False
137
+ order_nodes = []
138
+ order_settings = {}
71
139
  for node, _depth in iter_ast_nodes(emitter.ctx.ast):
72
140
  if isinstance(node, StrategyDecl):
73
141
  arguments = {STRATEGY_PARAMETERS[index] if index < len(STRATEGY_PARAMETERS)
@@ -75,6 +143,8 @@ def capabilities_document(emitter) -> str:
75
143
  for index, argument in enumerate(node.args)}
76
144
  unresolved.extend(arguments.keys() & node.kwargs.keys())
77
145
  arguments.update(node.kwargs)
146
+ order_settings.update({name: _literal(argument)[0] for name, argument in arguments.items()
147
+ if name in ORDER_SETTINGS})
78
148
  for name, argument in arguments.items():
79
149
  value, valid = _literal(argument)
80
150
  default = DECLARATION_DEFAULTS.get(name)
@@ -92,6 +162,9 @@ def capabilities_document(emitter) -> str:
92
162
  intrabar |= node.is_varip
93
163
  elif isinstance(node, FuncCall):
94
164
  name, namespace = emitter._resolve_callee(node.callee)
165
+ qualified = _expression(node.callee) or ""
166
+ if qualified.startswith("strategy.") and qualified not in READ_ONLY_STRATEGY_CALLS:
167
+ order_nodes.append(node)
95
168
  if namespace == "request" and name == "security_lower_tf":
96
169
  expression = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
97
170
  lower_symbols[id(expression)] = node.args[0] if node.args else node.kwargs.get("symbol")
@@ -118,22 +191,27 @@ def capabilities_document(emitter) -> str:
118
191
  unresolved.append(f"{_expression(node)} (stream endpoint is not batch-equivalent)")
119
192
 
120
193
  auxiliary = False
194
+ confirmed_requests = []
195
+ lowering_sites = {site["sec_id"]: site for site in emitter._security_eval_info}
121
196
  for site in emitter._security_calls:
197
+ lowering = lowering_sites[site["sec_id"]]
122
198
  symbol_node = site.get("symbol_node")
123
199
  if site.get("is_lower_tf_array"):
124
200
  symbol_node = lower_symbols.get(id(site.get("expr_node")), symbol_node)
125
201
  if isinstance(symbol_node, FuncCall) and site.get("heikinashi") and symbol_node.args:
126
202
  symbol_node = symbol_node.args[0]
127
203
  symbol = _expression(symbol_node) if symbol_node is not None else "syminfo.tickerid"
128
- timeframe = _expression(site.get("tf_node"))
204
+ timeframe = lowering.get("tf")
205
+ if timeframe is None:
206
+ timeframe = "timeframe.period" if lowering.get("tf_expr") == "script_tf_" else _expression(site.get("tf_node"))
129
207
  lookahead = _expression(site.get("lookahead_node")) or "barmerge.lookahead_off"
130
208
  gaps = _expression(site.get("gaps_node")) or "barmerge.gaps_off"
131
209
  if isinstance(symbol_node, Identifier) and symbol_node.name not in emitter._global_mutable_infos:
132
210
  symbol = _expression((getattr(emitter.ctx, "global_expr_map", {}) or {}).get(
133
211
  symbol_node.name, symbol_node))
134
- feed = "chart" if symbol in ("syminfo.tickerid", "syminfo.ticker", "") else "auxiliary"
212
+ feed = "auxiliary" if lowering.get("foreign") else "chart"
135
213
  auxiliary |= feed == "auxiliary"
136
- if not isinstance(site.get("tf_node"), StringLiteral) and timeframe != "timeframe.period":
214
+ if lowering.get("tf") is None and timeframe != "timeframe.period":
137
215
  unresolved.append(f"request.security[{site['sec_id']}].timeframe")
138
216
  if lookahead not in ("barmerge.lookahead_off", "barmerge.lookahead_on"):
139
217
  unresolved.append(f"request.security[{site['sec_id']}].lookahead")
@@ -143,9 +221,14 @@ def capabilities_document(emitter) -> str:
143
221
  "timeframe": timeframe,
144
222
  "lookahead": lookahead,
145
223
  "gaps": gaps,
146
- "heikinashi": bool(site.get("heikinashi")),
224
+ "heikinashi": bool(lowering.get("heikinashi")),
147
225
  "feed": feed,
148
226
  })
227
+ expression = _expression(site.get("expr_node"))
228
+ if expression in ("close", "close[1]", "ta.sma(close,4)", "ta.ema(close,3)") and "close" in (
229
+ getattr(emitter.ctx, "global_expr_map", {}) or {}):
230
+ unresolved.append("request.security.expression (user-bound close)")
231
+ confirmed_requests.append({**requests[-1], "expression": expression})
149
232
  for index, site in enumerate((emitter.ctx.ast.annotations or {}).get(CAPABILITY_UNPINNED_ANNOTATION, ())):
150
233
  requests.append({
151
234
  "function": site["function"],
@@ -166,13 +249,46 @@ def capabilities_document(emitter) -> str:
166
249
  "historical_probe_overrides": False,
167
250
  "intrabar_persistence": intrabar,
168
251
  }
169
- return json.dumps({"version": 1, "declarations": declarations, "requests": requests,
170
- "requirements": requirements, "unresolved": sorted(set(unresolved))},
171
- sort_keys=True, separators=(",", ":"), allow_nan=False)
252
+ short_ids = set()
253
+ conflicting_ids = set()
254
+ dynamic_entry_id = False
255
+ for node in order_nodes:
256
+ if _expression(node.callee) != "strategy.entry":
257
+ continue
258
+ arguments = _order_arguments(node)
259
+ identifier = arguments.get("id")
260
+ if not isinstance(identifier, StringLiteral):
261
+ dynamic_entry_id = True
262
+ elif _expression(arguments.get("direction")) == "strategy.short":
263
+ short_ids.add(identifier.value)
264
+ else:
265
+ conflicting_ids.add(identifier.value)
266
+ if dynamic_entry_id or conflicting_ids:
267
+ short_ids.clear()
268
+ orders = set(_order_shape(node, short_ids - conflicting_ids) for node in order_nodes)
269
+ if declarations["process_orders_on_close"]:
270
+ bracket = orders == {"entry:market", "exit:short_bracket"}
271
+ proven = (not order_settings and not bracket) or (
272
+ bracket and order_settings == PROVEN_BRACKET_SETTINGS) or (
273
+ orders == {"entry:market"} and order_settings == PROVEN_SLIPPED_SETTINGS
274
+ and not confirmed_requests and not intrabar)
275
+ if not proven:
276
+ orders.add("strategy() (unproven POOC sizing, slippage or account settings)")
277
+ confirmed = {"version": 1, "requests": confirmed_requests,
278
+ "orders": sorted(orders),
279
+ "intrabar_persistence": intrabar}
280
+ encode = lambda value: json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
281
+ return (encode({"version": 1, "declarations": declarations, "requests": requests,
282
+ "requirements": requirements, "unresolved": sorted(set(unresolved))}), encode(confirmed))
283
+
284
+
285
+ def capabilities_document(emitter) -> str:
286
+ return capabilities_documents(emitter)[0]
172
287
 
173
288
 
174
289
  def emit_capabilities_exports(emitter, lines: list[str]) -> None:
175
- literal = json.dumps(capabilities_document(emitter))
290
+ legacy, confirmed = capabilities_documents(emitter)
291
+ literal = json.dumps(legacy)
176
292
  lines.extend([
177
293
  "#ifdef PF_SETTINGS_API_VERSION",
178
294
  "#ifdef PF_CAPABILITIES_API_VERSION",
@@ -182,5 +298,22 @@ def emit_capabilities_exports(emitter, lines: list[str]) -> None:
182
298
  " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
183
299
  ' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
184
300
  f" ::pineforge::checked_settings::receipt({literal}, json, capacity, required);",
301
+ " });", " }",
302
+ " uint32_t strategy_confirmed_bar_api_version(void) { return 1u; }",
303
+ " int strategy_confirmed_bar_receipt(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {",
304
+ " if (required) *required = 0;",
305
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
306
+ ' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
307
+ f" ::pineforge::checked_settings::receipt({json.dumps(confirmed)}, json, capacity, required);",
185
308
  " });", " }", "#endif", "#endif",
186
309
  ])
310
+ from .order_shapes import order_shapes_document
311
+ lines[-2:-2] = [
312
+ " uint32_t strategy_order_shapes_api_version(void) { return 1u; }",
313
+ " int strategy_order_shapes_receipt(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {",
314
+ " if (required) *required = 0;",
315
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
316
+ ' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
317
+ f" ::pineforge::checked_settings::receipt({json.dumps(order_shapes_document(emitter))}, json, capacity, required);",
318
+ " });", " }",
319
+ ]
@@ -1,7 +1,36 @@
1
1
  """Opt-in settings metadata, validation and exception-contained C exports."""
2
2
 
3
+ import re
4
+
3
5
  from ..ast_nodes import Identifier, MemberAccess
4
6
  from ..errors import CompileError
7
+ from .tables import ALERT_FREQ_VALUES, NAME_ECHO_STRING_MEMBERS, ORDER_DIRECTION_MAP
8
+
9
+
10
+ _QUOTED_STRING = r'"(?:[^"\\]|\\.)*"'
11
+ _STRING_LITERAL = re.compile(rf'(?:std::string\({_QUOTED_STRING}\)|{_QUOTED_STRING})')
12
+
13
+
14
+ def _string_setting_arg(expr, lowered: str) -> str | None:
15
+ """Use the body's known string constants, never its inert visual codes.
16
+
17
+ Name-echo strings are accepted only for inventoried currency/format members.
18
+ Unknown constants leave the checked input unsupported; legacy getters
19
+ and setters retain their existing execution semantics.
20
+ """
21
+ if isinstance(expr, MemberAccess):
22
+ if not isinstance(expr.object, Identifier):
23
+ return None
24
+ namespace = expr.object.name
25
+ known = (
26
+ namespace == "alert" and expr.member in ALERT_FREQ_VALUES
27
+ or namespace == "order" and expr.member in ORDER_DIRECTION_MAP
28
+ or namespace == "session" and expr.member in ("regular", "extended")
29
+ or expr.member in NAME_ECHO_STRING_MEMBERS.get(namespace, ())
30
+ )
31
+ if not known:
32
+ return None
33
+ return lowered if _STRING_LITERAL.fullmatch(lowered) else None
5
34
 
6
35
 
7
36
  def _visit_setting_arg(emitter, expr) -> str:
@@ -51,7 +80,8 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
51
80
  key = emitter._input_key_literal(name)
52
81
  default = emitter._get_input_default(node)
53
82
  getter = emitter._input_getter_for_call(node, func_name, namespace)
54
- default_cpp = _visit_setting_arg(emitter, default) if default is not None else "0"
83
+ default_lowered = _visit_setting_arg(emitter, default) if default is not None else "0"
84
+ default_cpp = default_lowered
55
85
  default_cpp = emitter._coerce_string_input_default(getter, default_cpp)
56
86
  value_type = {
57
87
  "get_input_int": "int", "get_input_int64": "int",
@@ -63,6 +93,11 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
63
93
  option_values = []
64
94
  supported = "true"
65
95
  default_serialized = None
96
+ if getter == "get_input_string":
97
+ default_serialized = _string_setting_arg(default, default_lowered)
98
+ if default_serialized is None:
99
+ default_serialized = 'std::string("")'
100
+ supported = "false"
66
101
  names, merged = emitter._merged_args(node, func_name, namespace)
67
102
  arguments = dict(zip(names or [], merged))
68
103
  arguments.update(node.kwargs)
@@ -103,8 +138,19 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
103
138
  if declared:
104
139
  for option in declared:
105
140
  option_cpp = _visit_setting_arg(emitter, option)
106
- options.append(option_cpp if getter == "get_input_string" else
107
- f'::pineforge::checked_settings::number({option_cpp})')
141
+ if getter == "get_input_string":
142
+ option_cpp = _string_setting_arg(option, option_cpp)
143
+ if option_cpp is None:
144
+ supported = "false"
145
+ else:
146
+ options.append(option_cpp)
147
+ else:
148
+ options.append(f'::pineforge::checked_settings::number({option_cpp})')
149
+ if getter == "get_input_string":
150
+ if arguments.get("options") is not None and declared is None:
151
+ supported = "false"
152
+ if supported == "false":
153
+ options = []
108
154
  expression = f'{getter}({key}, {default_cpp})'
109
155
  effective = (expression if getter == "get_input_string" else
110
156
  f'::pineforge::checked_settings::number({expression})')
@@ -1281,6 +1281,7 @@ class TopLevelEmitter:
1281
1281
  lines.append(" pineforge::source::PineStrategyHost::set_strategy_override(overrides);")
1282
1282
  lines.append(" }")
1283
1283
  from .checked_settings import emit_settings_members
1284
+ self._order_shape_constructor = tuple(ctor_body)
1284
1285
  emit_settings_members(self, lines, ctor_body)
1285
1286
 
1286
1287
  if self._uses_recorded_requests():
@@ -1314,7 +1315,7 @@ class TopLevelEmitter:
1314
1315
  tf = info.get("tf")
1315
1316
  tf_expr = info.get("tf_expr")
1316
1317
  if tf:
1317
- tf_expr = f'"{tf}"'
1318
+ tf_expr = self._security_tf_literal(tf)
1318
1319
  elif tf == "" and not tf_expr:
1319
1320
  # An empty timeframe string is the chart's.
1320
1321
  tf_expr = "input_tf_"
@@ -1421,7 +1422,7 @@ class TopLevelEmitter:
1421
1422
  sec_id = info["sec_id"]
1422
1423
  symbol = self._security_tf_runtime_expr(info["symbol_node"])
1423
1424
  column = self._security_footprint_column(sec_id)
1424
- column = f', "{column}"' if column else ""
1425
+ column = f', "{self._cpp_string_escape(column)}"' if column else ""
1425
1426
  ignore_node = info.get("ignore_invalid_node")
1426
1427
  if ignore_node is None:
1427
1428
  ignore = "false"
@@ -1886,7 +1887,7 @@ class TopLevelEmitter:
1886
1887
  for pragma in pragmas:
1887
1888
  cpp_expr = self._visit_expr(pragma.expr_node)
1888
1889
  lines.append(
1889
- f'{inner}trace(std::string("{pragma.name}"), '
1890
+ f'{inner}trace(std::string("{self._cpp_string_escape(pragma.name)}"), '
1890
1891
  f"(double)({cpp_expr}));"
1891
1892
  )
1892
1893
  lines.append(f"{pad}}}")
@@ -1998,8 +1999,7 @@ class TopLevelEmitter:
1998
1999
  lines.append(" }")
1999
2000
  from .checked_settings import emit_settings_exports
2000
2001
  emit_settings_exports(lines)
2001
- from .capabilities import emit_capabilities_exports
2002
- emit_capabilities_exports(self, lines)
2002
+ self._capabilities_export_at = len(lines)
2003
2003
  if self._declares_bar_magnifier():
2004
2004
  # TradingView runs a script that declares use_bar_magnifier = true
2005
2005
  # on its bar magnifier; the host reads this export to run it on
@@ -434,6 +434,12 @@ class NamingHelper:
434
434
  .replace("\t", "\\t")
435
435
  )
436
436
 
437
+ @staticmethod
438
+ def _cpp_comment_escape(s: str) -> str:
439
+ """Keep text (a script's string rendered into it) inside the C++
440
+ ``/* ... */`` comment it is written in."""
441
+ return s.replace("/*", "/ *").replace("*/", "* /")
442
+
437
443
  def _initialise_safe_names(self, ast) -> None:
438
444
  """Reserve all authored spellings before assigning escaped ones.
439
445
 
@@ -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":
@@ -952,6 +952,7 @@ class ExprVisitor:
952
952
  if node.member == "isfirst":
953
953
  return "(bar_index_ == 0)"
954
954
  if node.member == "islast":
955
+ self._order_shape_host_reads.add("barstate_islast_")
955
956
  return "barstate_islast_"
956
957
  if node.member == "isnew":
957
958
  return "is_first_tick()"
@@ -962,6 +963,7 @@ class ExprVisitor:
962
963
  if node.member == "isrealtime":
963
964
  return "false"
964
965
  if node.member == "islastconfirmedhistory":
966
+ self._order_shape_host_reads.add("barstate_islast_")
965
967
  return "barstate_islast_"
966
968
  return "false"
967
969
  if ns in ("backadjustment", "settlement_as_close"):
@@ -1046,12 +1048,16 @@ class ExprVisitor:
1046
1048
  return ismarket
1047
1049
  return f"(!{ismarket} && {predicate})"
1048
1050
  if node.member == "isfirstbar":
1051
+ self._order_shape_host_reads.add("session_isfirstbar_")
1049
1052
  return "session_isfirstbar_"
1050
1053
  if node.member == "islastbar":
1054
+ self._order_shape_host_reads.add("session_islastbar_")
1051
1055
  return "session_islastbar_"
1052
1056
  if node.member == "isfirstbar_regular":
1057
+ self._order_shape_host_reads.add("session_isfirstbar_regular_")
1053
1058
  return "session_isfirstbar_regular_"
1054
1059
  if node.member == "islastbar_regular":
1060
+ self._order_shape_host_reads.add("session_islastbar_regular_")
1055
1061
  return "session_islastbar_regular_"
1056
1062
  return "false"
1057
1063
  if ns == "syminfo":
@@ -1083,7 +1089,7 @@ class ExprVisitor:
1083
1089
  if ns in SKIP_NAMESPACES:
1084
1090
  return "0"
1085
1091
  if ns == "currency":
1086
- return f'std::string("{node.member}")'
1092
+ return f'std::string("{self._cpp_string_escape(node.member)}")'
1087
1093
  if ns == "order":
1088
1094
  # order.ascending / order.descending. Unknown member -> "ascending".
1089
1095
  return ORDER_DIRECTION_MAP.get(node.member, 'std::string("ascending")')
@@ -1163,7 +1169,7 @@ class ExprVisitor:
1163
1169
  return f"{safe}.{node.member}"
1164
1170
  if name not in self.ctx.series_vars:
1165
1171
  # Unknown identifier — likely an enum value
1166
- return f'std::string("{node.member}")'
1172
+ return f'std::string("{self._cpp_string_escape(node.member)}")'
1167
1173
  return f"{obj}.{node.member}"
1168
1174
 
1169
1175
  def _operand_na_kind(self, node, cpp_type: str) -> str | None:
@@ -1100,10 +1100,8 @@ class StmtVisitor:
1100
1100
  # the backtest runtime. See: pineforge-codegen issue #10.
1101
1101
  if self._is_omitted_udt_field(node.target):
1102
1102
  recv = self._visit_expr(node.target.object)
1103
- lines.append(
1104
- f"{pad}/* drawing field assignment omitted: "
1105
- f"{recv}.{node.target.member} {node.op} ... */"
1106
- )
1103
+ omitted = self._cpp_comment_escape(f"{recv}.{node.target.member} {node.op}")
1104
+ lines.append(f"{pad}/* drawing field assignment omitted: {omitted} ... */")
1107
1105
  return
1108
1106
  # General expression target (e.g., member access)
1109
1107
  target_cpp = self._visit_mutable_expr(node.target)
@@ -2843,8 +2843,9 @@ class SupportChecker:
2843
2843
  # Bare suffix shorthand — Pine treats "D"/"W"/"M"/"S" as 1<suffix>.
2844
2844
  # Accept the same shorthand here.
2845
2845
  return None
2846
- if not digits.isdigit():
2847
- # catches "1.5", "-15", "abc", etc.
2846
+ if not digits.isdecimal():
2847
+ # catches "1.5", "-15", "abc", and digits int() does not read
2848
+ # ("²"), etc.
2848
2849
  return f"non-integer timeframe magnitude '{digits}'"
2849
2850
  n = int(digits)
2850
2851
  if n <= 0:
@@ -2859,15 +2860,11 @@ class SupportChecker:
2859
2860
  return
2860
2861
  if not isinstance(tf_node, StringLiteral):
2861
2862
  return
2862
- err = self._validate_pine_tf_literal(tf_node.value)
2863
- if err is None:
2863
+ refusal = invalid_tf_literal(tf_node.value, fn_label)
2864
+ if refusal is None:
2864
2865
  return
2865
- self._err(
2866
- tf_node,
2867
- f"{fn_label}: invalid timeframe literal '{tf_node.value}'. "
2868
- "Expected Pine TF format like '1', '15', '1H', '1D', '15S'.",
2869
- hint=err,
2870
- )
2866
+ message, hint = refusal
2867
+ self._err(tf_node, message, hint=hint)
2871
2868
 
2872
2869
  def _check_request_security_lower_tf_symbol(self, node: FuncCall) -> None:
2873
2870
  """``request.security_lower_tf`` reads the chart's intrabars: the
@@ -2912,6 +2909,18 @@ class SupportChecker:
2912
2909
  self._check_tf_literal(tf_node, "request.security_lower_tf")
2913
2910
 
2914
2911
 
2912
+ def invalid_tf_literal(tf_str: str, fn_label: str) -> tuple[str, str] | None:
2913
+ """``(message, hint)`` refusing ``tf_str`` as the timeframe of
2914
+ ``fn_label`` (``request.security`` / ``request.security_lower_tf``), or
2915
+ None when it is a Pine timeframe. The codegen refuses a timeframe string
2916
+ it resolves from a constant or a helper parameter with the same text."""
2917
+ err = SupportChecker._validate_pine_tf_literal(tf_str)
2918
+ if err is None:
2919
+ return None
2920
+ return (f"{fn_label}: invalid timeframe literal '{tf_str}'. "
2921
+ "Expected Pine TF format like '1', '15', '1H', '1D', '15S'.", err)
2922
+
2923
+
2915
2924
  # ---------------------------------------------------------------------------
2916
2925
  # Convenience entry point
2917
2926
  # ---------------------------------------------------------------------------
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "1.2.0",
2
+ "codegen": "1.3.0",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "7c9654f79d188babd5034d3ebedd2cdd60a8ec2866b70d8ad863a9f06341420e"
6
+ "sha256": "b058ef7c8ded42e7a80ccf551e62985e052d1e306aebf615ab9937544f060839"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "1.2.0",
2
+ "CODEGEN_VERSION": "1.3.0",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
Binary file