@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,319 @@
1
+ """Immutable execution metadata; it does not alter generated computation."""
2
+
3
+ import json
4
+ import math
5
+
6
+ from ..ast_nodes import BoolLiteral, FuncCall, Identifier, MemberAccess, NumberLiteral, StrategyDecl, StringLiteral, Subscript, UnaryOp, VarDecl
7
+ from ..external_requests import CAPABILITY_UNPINNED_ANNOTATION
8
+ from ..limits import iter_ast_nodes
9
+
10
+
11
+ STRATEGY_PARAMETERS = (
12
+ "title", "shorttitle", "overlay", "format", "precision", "scale", "pyramiding",
13
+ "calc_on_order_fills", "calc_on_every_tick", "max_bars_back",
14
+ "backtest_fill_limits_assumption", "default_qty_type", "default_qty_value",
15
+ "initial_capital", "currency", "slippage", "commission_type", "commission_value",
16
+ "process_orders_on_close", "close_entries_rule", "margin_long", "margin_short",
17
+ "explicit_plot_zorder", "max_lines_count", "max_labels_count", "max_boxes_count",
18
+ "calc_bars_count", "risk_free_rate", "use_bar_magnifier", "fill_orders_on_standard_ohlc",
19
+ "max_polylines_count", "dynamic_requests", "behind_chart", "calc_on_every_history_tick",
20
+ )
21
+
22
+
23
+ DECLARATION_DEFAULTS = {
24
+ "calc_on_every_tick": False,
25
+ "calc_on_order_fills": False,
26
+ "process_orders_on_close": False,
27
+ "use_bar_magnifier": False,
28
+ "fill_orders_on_standard_ohlc": False,
29
+ "backtest_fill_limits_assumption": 0,
30
+ "currency": "currency.NONE",
31
+ "timeframe": "",
32
+ "timeframe_gaps": True,
33
+ "dynamic_requests": True,
34
+ "calc_on_every_history_tick": False,
35
+ }
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
+
59
+
60
+ def _expression(node):
61
+ if isinstance(node, (StringLiteral, NumberLiteral, BoolLiteral)):
62
+ return node.value
63
+ if isinstance(node, Identifier):
64
+ return node.name
65
+ if isinstance(node, MemberAccess):
66
+ return f"{_expression(node.object)}.{node.member}"
67
+ if isinstance(node, FuncCall):
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)}]"
71
+ return None
72
+
73
+
74
+ def _literal(node):
75
+ if isinstance(node, (StringLiteral, NumberLiteral, BoolLiteral)):
76
+ return node.value, True
77
+ if isinstance(node, UnaryOp) and node.op in ("+", "-"):
78
+ value, valid = _literal(node.operand)
79
+ if valid and type(value) in (int, float):
80
+ return value if node.op == "+" else -value, True
81
+ if isinstance(node, MemberAccess):
82
+ value = _expression(node)
83
+ if value and value.split(".")[0] in ("currency", "format", "scale", "strategy"):
84
+ return value, True
85
+ return _expression(node), False
86
+
87
+
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]:
131
+ declarations = dict(DECLARATION_DEFAULTS)
132
+ unresolved = []
133
+ requests = []
134
+ lower_symbols = {}
135
+ intrabar = False
136
+ recorded = False
137
+ order_nodes = []
138
+ order_settings = {}
139
+ for node, _depth in iter_ast_nodes(emitter.ctx.ast):
140
+ if isinstance(node, StrategyDecl):
141
+ arguments = {STRATEGY_PARAMETERS[index] if index < len(STRATEGY_PARAMETERS)
142
+ else f"strategy() positional argument {index + 1}": argument
143
+ for index, argument in enumerate(node.args)}
144
+ unresolved.extend(arguments.keys() & node.kwargs.keys())
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})
148
+ for name, argument in arguments.items():
149
+ value, valid = _literal(argument)
150
+ default = DECLARATION_DEFAULTS.get(name)
151
+ if name in DECLARATION_DEFAULTS:
152
+ valid &= type(value) is type(default)
153
+ if name == "backtest_fill_limits_assumption":
154
+ valid = (type(value) is int and value >= 0) or (
155
+ type(value) is float and math.isfinite(value) and value >= 0 and value == int(value))
156
+ if valid:
157
+ value = int(value)
158
+ declarations[name] = value
159
+ if not valid or name not in STRATEGY_PARAMETERS and name not in DECLARATION_DEFAULTS:
160
+ unresolved.append(name)
161
+ elif isinstance(node, VarDecl):
162
+ intrabar |= node.is_varip
163
+ elif isinstance(node, FuncCall):
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)
168
+ if namespace == "request" and name == "security_lower_tf":
169
+ expression = node.args[2] if len(node.args) > 2 else node.kwargs.get("expression")
170
+ lower_symbols[id(expression)] = node.args[0] if node.args else node.kwargs.get("symbol")
171
+ parts = (node.annotations or {}).get("pf_recorded_key")
172
+ if parts is not None:
173
+ recorded = True
174
+ requests.append({
175
+ "function": f"{namespace}.{name}",
176
+ "symbol": _expression(node.args[0]) if node.args else None,
177
+ "timeframe": "",
178
+ "lookahead": f"barmerge.lookahead_{parts.get('lookahead', 'off')}",
179
+ "gaps": f"barmerge.gaps_{parts.get('gaps', 'off')}",
180
+ "heikinashi": False,
181
+ "feed": "recorded",
182
+ })
183
+ if isinstance(node, Identifier) and node.name == "timenow":
184
+ unresolved.append("timenow (bar timestamp approximation)")
185
+ if isinstance(node, Identifier) and node.name in ("last_bar_index", "last_bar_time"):
186
+ unresolved.append(f"{node.name} (stream endpoint is not batch-equivalent)")
187
+ if isinstance(node, MemberAccess) and _expression(node) == "barstate.isrealtime":
188
+ unresolved.append("barstate.isrealtime (compiled historical-only approximation)")
189
+ if isinstance(node, MemberAccess) and _expression(node) in (
190
+ "barstate.islast", "barstate.islastconfirmedhistory"):
191
+ unresolved.append(f"{_expression(node)} (stream endpoint is not batch-equivalent)")
192
+
193
+ auxiliary = False
194
+ confirmed_requests = []
195
+ lowering_sites = {site["sec_id"]: site for site in emitter._security_eval_info}
196
+ for site in emitter._security_calls:
197
+ lowering = lowering_sites[site["sec_id"]]
198
+ symbol_node = site.get("symbol_node")
199
+ if site.get("is_lower_tf_array"):
200
+ symbol_node = lower_symbols.get(id(site.get("expr_node")), symbol_node)
201
+ if isinstance(symbol_node, FuncCall) and site.get("heikinashi") and symbol_node.args:
202
+ symbol_node = symbol_node.args[0]
203
+ symbol = _expression(symbol_node) if symbol_node is not None else "syminfo.tickerid"
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"))
207
+ lookahead = _expression(site.get("lookahead_node")) or "barmerge.lookahead_off"
208
+ gaps = _expression(site.get("gaps_node")) or "barmerge.gaps_off"
209
+ if isinstance(symbol_node, Identifier) and symbol_node.name not in emitter._global_mutable_infos:
210
+ symbol = _expression((getattr(emitter.ctx, "global_expr_map", {}) or {}).get(
211
+ symbol_node.name, symbol_node))
212
+ feed = "auxiliary" if lowering.get("foreign") else "chart"
213
+ auxiliary |= feed == "auxiliary"
214
+ if lowering.get("tf") is None and timeframe != "timeframe.period":
215
+ unresolved.append(f"request.security[{site['sec_id']}].timeframe")
216
+ if lookahead not in ("barmerge.lookahead_off", "barmerge.lookahead_on"):
217
+ unresolved.append(f"request.security[{site['sec_id']}].lookahead")
218
+ requests.append({
219
+ "function": "request.security_lower_tf" if site.get("is_lower_tf_array") else "request.security",
220
+ "symbol": symbol,
221
+ "timeframe": timeframe,
222
+ "lookahead": lookahead,
223
+ "gaps": gaps,
224
+ "heikinashi": bool(lowering.get("heikinashi")),
225
+ "feed": feed,
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})
232
+ for index, site in enumerate((emitter.ctx.ast.annotations or {}).get(CAPABILITY_UNPINNED_ANNOTATION, ())):
233
+ requests.append({
234
+ "function": site["function"],
235
+ "symbol": _expression(site["symbol_node"]),
236
+ "timeframe": _expression(site["tf_node"]) if site["function"] in (
237
+ "request.security", "request.security_lower_tf") else "",
238
+ "lookahead": _expression(site["lookahead_node"]) or "barmerge.lookahead_off",
239
+ "gaps": _expression(site["gaps_node"]) or "barmerge.gaps_off",
240
+ "heikinashi": False,
241
+ "feed": "unpinned",
242
+ })
243
+ unresolved.append(f"{site['function']}[unpinned:{index}]")
244
+ requirements = {
245
+ "auxiliary_security_feeds": auxiliary,
246
+ "native_security_feeds": False,
247
+ "fx_curve": declarations["currency"] != "currency.NONE",
248
+ "recorded_series": recorded,
249
+ "historical_probe_overrides": False,
250
+ "intrabar_persistence": intrabar,
251
+ }
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]
287
+
288
+
289
+ def emit_capabilities_exports(emitter, lines: list[str]) -> None:
290
+ legacy, confirmed = capabilities_documents(emitter)
291
+ literal = json.dumps(legacy)
292
+ lines.extend([
293
+ "#ifdef PF_SETTINGS_API_VERSION",
294
+ "#ifdef PF_CAPABILITIES_API_VERSION",
295
+ " uint32_t strategy_capabilities_api_version(void) { return PF_CAPABILITIES_API_VERSION; }",
296
+ " int strategy_capabilities_receipt(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {",
297
+ " if (required) *required = 0;",
298
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
299
+ ' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
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);",
308
+ " });", " }", "#endif", "#endif",
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,6 +1,48 @@
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
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
34
+
35
+
36
+ def _visit_setting_arg(emitter, expr) -> str:
37
+ """An input argument's C++ for the settings metadata. The metadata is
38
+ emitted in the constructor, ahead of the script body: an error here is
39
+ held until the body is generated (``CodeGen.generate``), so the script's
40
+ first error in source order is the one raised."""
41
+ try:
42
+ return emitter._visit_expr(expr)
43
+ except CompileError as error:
44
+ emitter._defer_settings_error(error)
45
+ return "0"
4
46
 
5
47
 
6
48
  def emit_settings_members(emitter, lines: list[str], constructor: list[str]) -> None:
@@ -38,7 +80,8 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
38
80
  key = emitter._input_key_literal(name)
39
81
  default = emitter._get_input_default(node)
40
82
  getter = emitter._input_getter_for_call(node, func_name, namespace)
41
- default_cpp = emitter._visit_expr(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
42
85
  default_cpp = emitter._coerce_string_input_default(getter, default_cpp)
43
86
  value_type = {
44
87
  "get_input_int": "int", "get_input_int64": "int",
@@ -50,6 +93,11 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
50
93
  option_values = []
51
94
  supported = "true"
52
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"
53
101
  names, merged = emitter._merged_args(node, func_name, namespace)
54
102
  arguments = dict(zip(names or [], merged))
55
103
  arguments.update(node.kwargs)
@@ -89,16 +137,27 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
89
137
  declared = getattr(arguments.get("options"), "elements", None)
90
138
  if declared:
91
139
  for option in declared:
92
- option_cpp = emitter._visit_expr(option)
93
- options.append(option_cpp if getter == "get_input_string" else
94
- f'::pineforge::checked_settings::number({option_cpp})')
140
+ option_cpp = _visit_setting_arg(emitter, option)
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 = []
95
154
  expression = f'{getter}({key}, {default_cpp})'
96
155
  effective = (expression if getter == "get_input_string" else
97
156
  f'::pineforge::checked_settings::number({expression})')
98
157
  if default_serialized is None:
99
158
  default_serialized = (default_cpp if value_type in ("string", "source") else
100
159
  f'::pineforge::checked_settings::number({default_cpp})')
101
- constraints = [emitter._visit_expr(arguments[name]) if arguments.get(name) is not None
160
+ constraints = [_visit_setting_arg(emitter, arguments[name]) if arguments.get(name) is not None
102
161
  else "std::numeric_limits<double>::quiet_NaN()"
103
162
  for name in ("minval", "maxval", "step")]
104
163
  metadata = (f'{{{key}, "{value_type}", {default_serialized}, '
@@ -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,6 +1999,7 @@ class TopLevelEmitter:
1998
1999
  lines.append(" }")
1999
2000
  from .checked_settings import emit_settings_exports
2000
2001
  emit_settings_exports(lines)
2002
+ self._capabilities_export_at = len(lines)
2001
2003
  if self._declares_bar_magnifier():
2002
2004
  # TradingView runs a script that declares use_bar_magnifier = true
2003
2005
  # on its bar magnifier; the host reads this export to run it on
@@ -282,6 +282,7 @@ CPP_EMITTER_NAMES = frozenset("""
282
282
  configure_security_evaluators snapshot_script_state restore_script_state
283
283
  commit_script_state set_strategy_override set_input
284
284
  set_magnifier_volume_weighted strategy_declares_bar_magnifier fill_report run
285
+ strategy_capabilities_api_version strategy_capabilities_receipt
285
286
  precalculate
286
287
  strategy_entry strategy_close strategy_close_all strategy_exit
287
288
  strategy_exit_cancel_bracket strategy_cancel strategy_cancel_all strategy_order
@@ -433,6 +434,12 @@ class NamingHelper:
433
434
  .replace("\t", "\\t")
434
435
  )
435
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
+
436
443
  def _initialise_safe_names(self, ast) -> None:
437
444
  """Reserve all authored spellings before assigning escaped ones.
438
445