@pineforge/codegen-pyodide 1.0.1 → 1.1.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/glue.py CHANGED
@@ -40,4 +40,5 @@ def transpile_json(source: str) -> str:
40
40
  # A script that transpiled carries its warnings in the same entry format.
41
41
  return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"],
42
42
  "strategyParams": full["strategyParams"],
43
- "diagnostics": _diagnostic_entries(full["diagnostics"])})
43
+ "diagnostics": _diagnostic_entries(full["diagnostics"]),
44
+ "requests": full["requests"]})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineforge/codegen-pyodide",
3
- "version": "1.0.1",
3
+ "version": "1.1.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",
@@ -13,6 +13,7 @@ from .finite_ta_length import expand_finite_choice_extrema_lengths
13
13
  from .library_inline import inline_libraries
14
14
  from .limits import TimeBudget, check_ast_depth, check_source_size, ensure_recursion_headroom
15
15
  from .pragmas import extract_pf_trace_pragmas
16
+ from .request_discovery import discover_requests, request_sites
16
17
  from .security_contexts import specialize_security_contexts
17
18
  from .block_locals import rename_block_locals
18
19
  from .support_checker import check_support as _support_diagnostics
@@ -52,7 +53,9 @@ def _generate(pine_source: str, check_support: bool, filename: str,
52
53
  the member of its name cannot hold gets a name of its own, and the
53
54
  pipeline runs again (``block_locals``).
54
55
 
55
- Returns ``(codegen, ctx, cpp, support_diagnostics)``."""
56
+ Returns ``(codegen, ctx, cpp, support_diagnostics, sites)``: ``sites``
57
+ are the requests of another symbol's data the support checker lowered
58
+ (``request_discovery.request_sites``)."""
56
59
  budget = None
57
60
  clones: frozenset[str] = frozenset()
58
61
  renamed: frozenset = frozenset()
@@ -67,6 +70,7 @@ def _generate(pine_source: str, check_support: bool, filename: str,
67
70
  if any(d.level == Level.ERROR for d in support_diagnostics):
68
71
  raise CompileError(support_diagnostics)
69
72
  budget.check(phase=Phase.ANALYZER)
73
+ sites = request_sites(ast)
70
74
  ast = lower_no_data_requests(ast)
71
75
  ast = specialize_security_contexts(ast, filename=filename)
72
76
  ast = bind_builtin_keywords(expand_finite_choice_extrema_lengths(ast))
@@ -86,18 +90,18 @@ def _generate(pine_source: str, check_support: bool, filename: str,
86
90
  apart = gen.block_locals_needing_names - renamed
87
91
  if apart:
88
92
  renamed |= apart
89
- del ast, ctx, gen, cpp
93
+ del ast, ctx, gen, cpp, sites
90
94
  continue
91
95
  needed = gen.session_functions_needing_clones
92
96
  if not needed:
93
- return gen, ctx, cpp, support_diagnostics
97
+ return gen, ctx, cpp, support_diagnostics, sites
94
98
  # Only uncloned functions ask, so the set grows each time and the
95
99
  # loop ends by the time every function that reads a flag at an
96
100
  # offset is cloned.
97
101
  if needed <= clones:
98
102
  raise AssertionError("a cloned function asked for session clones again")
99
103
  clones |= needed
100
- del ast, ctx, gen, cpp
104
+ del ast, ctx, gen, cpp, sites
101
105
 
102
106
 
103
107
  def transpile(pine_source: str, *, check_support: bool = True, filename: str = "<input>",
@@ -140,7 +144,8 @@ def transpile(pine_source: str, *, check_support: bool = True, filename: str = "
140
144
  Returns:
141
145
  Generated C++ source string.
142
146
  """
143
- _gen, _ctx, cpp, _support = _generate(pine_source, check_support, filename, libraries)
147
+ _gen, _ctx, cpp, _support, _sites = _generate(pine_source, check_support, filename,
148
+ libraries)
144
149
  return cpp
145
150
 
146
151
 
@@ -161,26 +166,36 @@ def transpile_full(pine_source: str, *, check_support: bool = True,
161
166
  source order). Each has ``title`` / ``type`` /
162
167
  ``default`` and optionally ``min`` / ``max`` / ``step`` / ``options``
163
168
  (omitted when the corresponding signature argument is absent or
164
- references a non-const value). See
165
- :meth:`CodeGen.extract_input_manifest`.
169
+ references a non-const value); an ``input.symbol`` entry also has
170
+ ``kind: "symbol"``. See :meth:`CodeGen.extract_input_manifest`.
166
171
  - ``strategyParams``: the literal ``strategy(...)`` kwargs the analyzer
167
172
  surfaced (e.g. ``initial_capital``, ``pyramiding``).
168
173
  - ``diagnostics``: the warnings (:class:`~pineforge_codegen.errors.Diagnostic`,
169
174
  ``Level.WARNING``) the support checker and the analyzer raised for a
170
175
  script that transpiled -- e.g. an approximated ``ta.vwap`` anchor. An
171
176
  error still raises ``CompileError``, which carries the warnings too.
177
+ - ``requests``: every request site that reads another symbol's feed,
178
+ by line: its symbol and timeframe as registration computes them
179
+ before the first bar (``literal`` / ``input`` / ``computed`` /
180
+ ``unresolvable``; ``literal`` / ``chart`` / ``input`` / ``computed``),
181
+ ``lookahead``, ``gaps`` and ``ignore_invalid_symbol``. A site lowered
182
+ to ``na`` (its value reaches display sinks only) or in a helper
183
+ nothing reaches is not listed. See
184
+ :mod:`pineforge_codegen.request_discovery`.
172
185
 
173
186
  Args mirror :func:`transpile`.
174
187
 
175
188
  Returns:
176
189
  ``{"cpp": str, "inputs": list[dict], "strategyParams": dict,
177
- "diagnostics": list[Diagnostic]}``.
190
+ "diagnostics": list[Diagnostic], "requests": list[dict]}``.
178
191
  """
179
- gen, ctx, cpp, support_diagnostics = _generate(pine_source, check_support, filename, libraries)
192
+ gen, ctx, cpp, support_diagnostics, sites = _generate(
193
+ pine_source, check_support, filename, libraries)
180
194
  return {
181
195
  "cpp": cpp,
182
196
  "inputs": gen.extract_input_manifest(),
183
197
  "strategyParams": dict(ctx.strategy_params),
184
198
  "diagnostics": [d for d in (*support_diagnostics, *ctx.diagnostics)
185
199
  if d.level == Level.WARNING],
200
+ "requests": discover_requests(gen, ctx, sites),
186
201
  }
@@ -3797,6 +3797,7 @@ class Analyzer(CallHandlers, DiagnosticsHelper, TypeHelper):
3797
3797
  symbol=sec.symbol,
3798
3798
  foreign=sec.foreign,
3799
3799
  ignore_invalid=sec.ignore_invalid,
3800
+ loc=sec.loc,
3800
3801
  )
3801
3802
  new_calls.append(clone)
3802
3803
  next_sec_id += 1
@@ -876,6 +876,7 @@ class CallHandlers:
876
876
  dead=bool(notes.get(DEAD_ANNOTATION)),
877
877
  foreign=notes.get(LOWERING_ANNOTATION) == FEED_LOWERING,
878
878
  ignore_invalid=node.kwargs.get("ignore_invalid_symbol"),
879
+ loc=node.loc,
879
880
  ))
880
881
 
881
882
  return PineType.STRING if string_result else PineType.FLOAT
@@ -998,6 +999,7 @@ class CallHandlers:
998
999
  is_lower_tf_array=True,
999
1000
  context_resolved=context is not None,
1000
1001
  dead=bool(notes.get(DEAD_ANNOTATION)),
1002
+ loc=node.loc,
1001
1003
  ))
1002
1004
 
1003
1005
  # ``request.security_lower_tf`` returns an array; the value-level
@@ -196,6 +196,9 @@ class SecurityCallInfo:
196
196
  # ``symbol`` string and ``ignore_invalid_symbol`` (the node, or None).
197
197
  foreign: bool = False
198
198
  ignore_invalid: Any = None
199
+ # The request call's source location (``request_discovery`` names the
200
+ # site by its line).
201
+ loc: Any = None
199
202
 
200
203
 
201
204
  @dataclass
@@ -0,0 +1,272 @@
1
+ """Opt-in settings metadata, validation and exception-contained C exports."""
2
+
3
+ from ..ast_nodes import Identifier, MemberAccess
4
+
5
+
6
+ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) -> None:
7
+ lines.extend([
8
+ " bool _pf_setting_failed_ = false;",
9
+ " std::string _pf_setting_failure_;",
10
+ " void _pf_record_failure(const char* entrypoint, const char* message) noexcept {",
11
+ " try { last_error_ = entrypoint; last_error_ += \": \"; last_error_ += message; } catch (...) {}",
12
+ " }",
13
+ " void _pf_record_setting_failure(const char* entrypoint, const char* message) noexcept {",
14
+ " if (!_pf_setting_failed_) {",
15
+ " _pf_setting_failed_ = true;",
16
+ ' try { _pf_setting_failure_ = entrypoint; _pf_setting_failure_ += ": "; _pf_setting_failure_ += message; } catch (...) {}',
17
+ " }",
18
+ " _pf_refuse_failed_setting(nullptr);",
19
+ " }",
20
+ " void _pf_require_settings_ok() const {",
21
+ "#ifdef PF_SETTINGS_API_VERSION",
22
+ ' if (_pf_setting_failed_) throw ::pineforge::checked_settings::LatchedSettingsFailure(_pf_setting_failure_.empty() ? "legacy strategy setter failed" : _pf_setting_failure_);',
23
+ "#else",
24
+ ' if (_pf_setting_failed_) throw std::runtime_error(_pf_setting_failure_.empty() ? "legacy strategy setter failed" : _pf_setting_failure_);',
25
+ "#endif",
26
+ " }",
27
+ " bool _pf_refuse_failed_setting(ReportC* out) noexcept {",
28
+ " if (!_pf_setting_failed_) return false;",
29
+ ' try { last_error_ = _pf_setting_failure_.empty() ? "legacy strategy setter failed" : _pf_setting_failure_; } catch (...) {}',
30
+ " if (out) *out = ReportC{};",
31
+ " return true;",
32
+ " }",
33
+ ])
34
+ inputs = []
35
+ for node, binding in emitter._global_input_calls_with_names():
36
+ func_name, namespace = emitter._resolve_callee(node.callee)
37
+ name = emitter._get_input_title(node, var_name=binding)
38
+ key = emitter._input_key_literal(name)
39
+ default = emitter._get_input_default(node)
40
+ getter = emitter._input_getter_for_call(node, func_name, namespace)
41
+ default_cpp = emitter._visit_expr(default) if default is not None else "0"
42
+ default_cpp = emitter._coerce_string_input_default(getter, default_cpp)
43
+ value_type = {
44
+ "get_input_int": "int", "get_input_int64": "int",
45
+ "get_input_double": "float", "get_input_bool": "bool",
46
+ "get_input_string": "string",
47
+ }[getter]
48
+ kind = emitter._FORM_TYPE.get(func_name, value_type) if namespace == "input" else value_type
49
+ options = []
50
+ option_values = []
51
+ supported = "true"
52
+ default_serialized = None
53
+ names, merged = emitter._merged_args(node, func_name, namespace)
54
+ arguments = dict(zip(names or [], merged))
55
+ arguments.update(node.kwargs)
56
+ if emitter._is_source_input(node):
57
+ value_type = "source"
58
+ source = emitter._source_defval_to_base_series(default)[5:-1]
59
+ default_cpp = emitter._input_key_literal(source)
60
+ effective = f'(inputs_.count({key}) ? inputs_.at({key}) : {default_cpp})'
61
+ options = [emitter._input_key_literal(value)
62
+ for value in sorted(emitter._NATIVE_SOURCE_SERIES)]
63
+ elif namespace == "input" and func_name == "enum":
64
+ value_type = "enum"
65
+ declared = getattr(arguments.get("options"), "elements", None)
66
+ enum_members = ([default] if isinstance(default, MemberAccess) else []) + list(declared or [])
67
+ enum_names = {member.object.name for member in enum_members
68
+ if isinstance(member, MemberAccess) and isinstance(member.object, Identifier)}
69
+ if (len(enum_names) == 1 and enum_members
70
+ and all(isinstance(member, MemberAccess) and isinstance(member.object, Identifier)
71
+ for member in enum_members)):
72
+ enum_name = next(iter(enum_names))
73
+ members = emitter._enum_defs.get(enum_name, [])
74
+ selected = [member.member for member in declared] if declared is not None else members
75
+ options = [emitter._input_key_literal(f"{enum_name}.{member}")
76
+ for member in selected]
77
+ option_values = [emitter._input_key_literal(str(members.index(member)))
78
+ for member in selected if member in members]
79
+ supported = "true" if options and len(options) == len(option_values) else "false"
80
+ else:
81
+ supported = "false"
82
+ if not isinstance(default, MemberAccess):
83
+ supported = "false"
84
+ default_serialized = 'std::string("na")'
85
+ effective = f'(inputs_.count({key}) ? inputs_.at({key}) : std::string("na"))'
86
+ else:
87
+ effective = f'::pineforge::checked_settings::number({getter}({key}, {default_cpp}))'
88
+ else:
89
+ declared = getattr(arguments.get("options"), "elements", None)
90
+ if declared:
91
+ 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})')
95
+ expression = f'{getter}({key}, {default_cpp})'
96
+ effective = (expression if getter == "get_input_string" else
97
+ f'::pineforge::checked_settings::number({expression})')
98
+ if default_serialized is None:
99
+ default_serialized = (default_cpp if value_type in ("string", "source") else
100
+ f'::pineforge::checked_settings::number({default_cpp})')
101
+ constraints = [emitter._visit_expr(arguments[name]) if arguments.get(name) is not None
102
+ else "std::numeric_limits<double>::quiet_NaN()"
103
+ for name in ("minval", "maxval", "step")]
104
+ metadata = (f'{{{key}, "{value_type}", {default_serialized}, '
105
+ f'{{{", ".join(options)}}}, {", ".join(constraints)}, '
106
+ f'{64 if getter == "get_input_int64" else 32}, {supported}, '
107
+ f'{{{", ".join(option_values)}}}, "{kind}"}}')
108
+ inputs.append((metadata, effective))
109
+
110
+ overrides = [
111
+ ("initial_capital", "float", "initial_capital", "initial_capital", "0.0"),
112
+ ("commission_value", "float", "commission_value", "commission_value", "0.0"),
113
+ ("default_qty_value", "float", "default_qty_value", "default_qty_value", "0.0"),
114
+ ("pyramiding", "int", "pyramiding", "pyramiding", "0.0"),
115
+ ("slippage", "int", "slippage", "slippage", "0.0"),
116
+ ("process_orders_on_close", "bool", "process_orders_on_close", "process_orders_on_close", "nan"),
117
+ ("calc_on_order_fills", "bool", "calc_on_order_fills", "calc_on_order_fills", "nan"),
118
+ ("close_entries_rule", "string", "close_entries_rule_any", "close_entries_rule", "nan"),
119
+ ("default_qty_type", "string", "default_qty_type", "default_qty_type", "nan"),
120
+ ("commission_type", "string", "commission_type", "commission_type", "nan"),
121
+ ]
122
+ enum_options = {
123
+ "close_entries_rule": ["FIFO", "ANY"],
124
+ "default_qty_type": ["fixed", "percent_of_equity", "cash"],
125
+ "commission_type": ["percent", "cash_per_order", "cash_per_contract"],
126
+ }
127
+ lines.extend([
128
+ "#ifdef PF_SETTINGS_API_VERSION",
129
+ " static pineforge::source::PineStrategyConfig _pf_settings_declared_config() {",
130
+ " pineforge::source::PineStrategyConfig cfg{};",
131
+ ])
132
+ lines.extend(statement for statement in constructor if statement.startswith(" cfg."))
133
+ lines.extend([
134
+ " return cfg;", " }",
135
+ " std::vector<::pineforge::checked_settings::Setting> _pf_settings_inputs() const {",
136
+ " return {",
137
+ ])
138
+ lines.extend(f" {metadata}," for metadata, _effective in inputs)
139
+ lines.extend([
140
+ " };", " }",
141
+ " std::vector<::pineforge::checked_settings::Setting> _pf_settings_overrides() const {",
142
+ " const double _pf_nan = std::numeric_limits<double>::quiet_NaN();",
143
+ " const auto _pf_defaults = _pf_settings_declared_config();",
144
+ " return {",
145
+ ])
146
+ override_effective = []
147
+ for name, value_type, config_field, override_field, minimum in overrides:
148
+ raw = f'config_.{config_field}'
149
+ declared_default = f'_pf_defaults.{config_field}'
150
+ overridden = f'override_.{override_field}'
151
+ selected = (f'(std::isnan({overridden}) ? {raw} : {overridden})'
152
+ if value_type == "float" else
153
+ f'({overridden} < 0 ? {raw} : {overridden})')
154
+ if name in enum_options:
155
+ options = enum_options[name]
156
+ default_value = f'_pf_{name}_word({declared_default})'
157
+ effective = f'_pf_{name}_word({selected})'
158
+ else:
159
+ options = []
160
+ default_value = f'::pineforge::checked_settings::number({declared_default})'
161
+ effective = f'::pineforge::checked_settings::number({selected})'
162
+ if value_type == "bool":
163
+ effective = f'::pineforge::checked_settings::number(static_cast<bool>({selected}))'
164
+ option_cpp = ", ".join(emitter._input_key_literal(option) for option in options)
165
+ floor = "_pf_nan" if minimum == "nan" else minimum
166
+ lines.append(f' {{"{name}", "{value_type}", {default_value}, '
167
+ f'{{{option_cpp}}}, {floor}}},')
168
+ override_effective.append(effective)
169
+ lines.extend([" };", " }"])
170
+ for name, options in enum_options.items():
171
+ lines.append(f' static std::string _pf_{name}_word(int _pf_value) {{')
172
+ for index, option in enumerate(options):
173
+ lines.append(f' if (_pf_value == {index}) return "{option}";')
174
+ lines.extend([' return "invalid";', ' }'])
175
+ lines.extend([
176
+ " void _pf_set_input_checked(const std::string& _pf_key, const std::string& _pf_value) {",
177
+ " if (_pf_refuse_failed_setting(nullptr)) throw ::pineforge::checked_settings::Error{PF_SETTINGS_RUN_FAILED, last_error_.c_str()};",
178
+ ' ::pineforge::checked_settings::require(script_bars_processed() == 0 && stream_phase_ == StreamPhase::IDLE, "settings are frozen after execution begins", PF_SETTINGS_UNSUPPORTED);',
179
+ " const auto _pf_inputs = _pf_settings_inputs();",
180
+ " const ::pineforge::checked_settings::Setting* _pf_match = nullptr;",
181
+ " for (const auto& _pf_input : _pf_inputs) {",
182
+ " if (_pf_input.name != _pf_key) continue;",
183
+ ' ::pineforge::checked_settings::require(_pf_match == nullptr, "ambiguous input key", PF_SETTINGS_UNSUPPORTED);',
184
+ " _pf_match = &_pf_input;",
185
+ " }",
186
+ ' ::pineforge::checked_settings::require(_pf_match != nullptr, "unknown input key");',
187
+ " const auto _pf_canonical = ::pineforge::checked_settings::validate(*_pf_match, _pf_value);",
188
+ " set_input(_pf_key, _pf_canonical);",
189
+ ' ::pineforge::checked_settings::require(inputs_.count(_pf_key) && inputs_.at(_pf_key) == _pf_canonical, "input was not installed", PF_SETTINGS_UNSUPPORTED);',
190
+ " }",
191
+ " void _pf_set_override_checked(const std::string& _pf_key, const std::string& _pf_value) {",
192
+ " if (_pf_refuse_failed_setting(nullptr)) throw ::pineforge::checked_settings::Error{PF_SETTINGS_RUN_FAILED, last_error_.c_str()};",
193
+ ' ::pineforge::checked_settings::require(script_bars_processed() == 0 && stream_phase_ == StreamPhase::IDLE, "settings are frozen after execution begins", PF_SETTINGS_UNSUPPORTED);',
194
+ " for (const auto& _pf_override : _pf_settings_overrides()) {",
195
+ " if (_pf_override.name != _pf_key) continue;",
196
+ " auto _pf_alias = _pf_value;",
197
+ ])
198
+ for name, options in enum_options.items():
199
+ lines.append(f' if (_pf_key == "{name}") {{')
200
+ for index, option in enumerate(options):
201
+ aliases = [str(index)]
202
+ if name == "close_entries_rule":
203
+ aliases.append(option.lower())
204
+ else:
205
+ prefix = "strategy.commission." if name == "commission_type" else "strategy."
206
+ aliases.append(prefix + option)
207
+ condition = " || ".join(f'_pf_value == "{alias}"' for alias in aliases)
208
+ lines.append(f' if ({condition}) _pf_alias = "{option}";')
209
+ lines.append(" }")
210
+ lines.extend([
211
+ " const auto _pf_canonical = ::pineforge::checked_settings::validate(_pf_override, _pf_alias);",
212
+ " set_strategy_override(_pf_key, _pf_canonical);",
213
+ " return;",
214
+ " }",
215
+ ' throw ::pineforge::checked_settings::Error{PF_SETTINGS_INVALID_ARGUMENT, "unknown override key"};',
216
+ " }",
217
+ " std::string _pf_settings_receipt() const {",
218
+ ' ::pineforge::checked_settings::require(!_pf_setting_failed_, _pf_setting_failure_.empty() ? "legacy strategy setter failed" : _pf_setting_failure_.c_str(), PF_SETTINGS_RUN_FAILED);',
219
+ ' std::string _pf_document = "{\\\"version\\\":1,\\\"inputs\\\":[";',
220
+ " const auto _pf_inputs = _pf_settings_inputs();",
221
+ ])
222
+ for index, (_metadata, effective) in enumerate(inputs):
223
+ if index:
224
+ lines.append(" _pf_document += ',';")
225
+ lines.append(f" _pf_document += ::pineforge::checked_settings::describe(_pf_inputs[{index}], {effective});")
226
+ lines.extend([
227
+ ' _pf_document += "],\\\"overrides\\\":[";',
228
+ " const auto _pf_overrides = _pf_settings_overrides();",
229
+ ])
230
+ for index, effective in enumerate(override_effective):
231
+ if index:
232
+ lines.append(" _pf_document += ',';")
233
+ lines.append(f" _pf_document += ::pineforge::checked_settings::describe(_pf_overrides[{index}], {effective});")
234
+ lines.extend([' return _pf_document + "]}";', " }", "#endif"])
235
+
236
+
237
+ def emit_settings_exports(lines: list[str]) -> None:
238
+ lines.extend([
239
+ "#ifdef PF_SETTINGS_API_VERSION",
240
+ " uint32_t strategy_settings_api_version(void) { return PF_SETTINGS_API_VERSION; }",
241
+ " int strategy_create_checked(const char* params_json, void** out, char* error, size_t error_capacity) {",
242
+ " if (out) *out = nullptr;",
243
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
244
+ ' ::pineforge::checked_settings::require(out != nullptr, "strategy output pointer is null");',
245
+ ' ::pineforge::checked_settings::require(!params_json || !*params_json, "params_json is reserved; use checked setters", PF_SETTINGS_UNSUPPORTED);',
246
+ " *out = new GeneratedStrategy();",
247
+ " });",
248
+ " }",
249
+ ])
250
+ for kind in ("input", "override"):
251
+ lines.extend([
252
+ f" int strategy_set_{kind}_checked(void* s, const char* key, const char* value, char* error, size_t error_capacity) {{",
253
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
254
+ ' ::pineforge::checked_settings::require(s && key && value, "null strategy, key or value");',
255
+ f" static_cast<GeneratedStrategy*>(s)->_pf_set_{kind}_checked(key, value);",
256
+ " });", " }",
257
+ ])
258
+ lines.extend([
259
+ " int strategy_get_effective_settings(void* s, char* json, size_t capacity, size_t* required, char* error, size_t error_capacity) {",
260
+ " if (required) *required = 0;",
261
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
262
+ ' ::pineforge::checked_settings::require(s != nullptr, "null strategy");',
263
+ " ::pineforge::checked_settings::receipt(static_cast<GeneratedStrategy*>(s)->_pf_settings_receipt(), json, capacity, required);",
264
+ " });", " }",
265
+ " int run_backtest_full_checked(void* s, Bar* bars, int n, const char* input_tf, const char* script_tf, int bar_magnifier, int magnifier_samples, int magnifier_dist, ReportC* out, char* error, size_t error_capacity) {",
266
+ " return ::pineforge::checked_settings::boundary(error, error_capacity, [&] {",
267
+ ' ::pineforge::checked_settings::require(s && out && n >= 0 && (n == 0 || bars), "invalid batch arguments");',
268
+ " _pf_run_backtest_full_impl(s, bars, n, input_tf, script_tf, bar_magnifier, magnifier_samples, magnifier_dist, out);",
269
+ " const auto& _pf_error = static_cast<GeneratedStrategy*>(s)->last_error();",
270
+ ' ::pineforge::checked_settings::require(_pf_error.empty(), _pf_error.c_str(), PF_SETTINGS_RUN_FAILED);',
271
+ " });", " }", "#endif",
272
+ ])
@@ -130,6 +130,8 @@ class TopLevelEmitter:
130
130
  )
131
131
  lines.append('#include <pineforge/source/pine_strategy_host.hpp>')
132
132
  lines.append('#include <pineforge/ta.hpp>')
133
+ lines.extend(['#if __has_include(<pineforge/checked_settings.hpp>)',
134
+ '#include <pineforge/checked_settings.hpp>', '#endif'])
133
135
  if self._ta_uses_dynamic_lengths():
134
136
  lines.append('#include <pineforge/source/pine_ta_length.hpp>')
135
137
  lines.append('#include <pineforge/math.hpp>')
@@ -598,6 +600,7 @@ class TopLevelEmitter:
598
600
  '#error "Generated lifecycle reset requires a matching PineForge engine; rebuild with script-run preparation support"',
599
601
  "#endif",
600
602
  " void prepare_script_run(const Bar* bars, int n, bool allow_precalculation) override {",
603
+ " _pf_require_settings_ok();",
601
604
  " _pf_script_state_checkpoint_.reset();",
602
605
  ])
603
606
  seen: set[str] = set()
@@ -1277,6 +1280,8 @@ class TopLevelEmitter:
1277
1280
  lines.append(" }")
1278
1281
  lines.append(" pineforge::source::PineStrategyHost::set_strategy_override(overrides);")
1279
1282
  lines.append(" }")
1283
+ from .checked_settings import emit_settings_members
1284
+ emit_settings_members(self, lines, ctor_body)
1280
1285
 
1281
1286
  if self._uses_recorded_requests():
1282
1287
  lines.extend([
@@ -1889,24 +1894,38 @@ class TopLevelEmitter:
1889
1894
  def _emit_extern_c(self, lines: list[str]) -> None:
1890
1895
  lines.append('extern "C" {')
1891
1896
  lines.append(" void* strategy_create(const char* params_json) {")
1892
- lines.append(" return new GeneratedStrategy();")
1897
+ lines.append(" try { return new GeneratedStrategy(); } catch (...) { return nullptr; }")
1893
1898
  lines.append(" }")
1894
1899
  lines.append(" void run_backtest(void* s, Bar* bars, int n, ReportC* out) {")
1900
+ lines.append(" try {")
1895
1901
  lines.append(" auto* strat = static_cast<GeneratedStrategy*>(s);")
1902
+ lines.append("#ifndef PF_SETTINGS_API_VERSION")
1903
+ lines.append(" if (strat->_pf_refuse_failed_setting(out)) return;")
1904
+ lines.append("#endif")
1896
1905
  if self._security_calls:
1897
1906
  # If there are security calls, use the full run path. Pass empty strings
1898
1907
  # so the C++ runtime auto-detects input_tf from bar timestamps.
1899
1908
  lines.append(' strat->run(bars, n, "", "", false, 4, MagnifierDistribution::ENDPOINTS);')
1900
1909
  else:
1901
1910
  lines.append(" strat->run(bars, n);")
1902
- lines.append(" strat->fill_report(out);")
1911
+ lines.append(" if (!strat->_pf_refuse_failed_setting(out)) strat->fill_report(out);")
1912
+ lines.append(' } catch (const std::exception& _pf_error) {')
1913
+ lines.append(' if (out) *out = ReportC{};')
1914
+ lines.append(' if (s) static_cast<GeneratedStrategy*>(s)->_pf_record_failure("run_backtest", _pf_error.what());')
1915
+ lines.append(' } catch (...) {')
1916
+ lines.append(' if (out) *out = ReportC{};')
1917
+ lines.append(' if (s) static_cast<GeneratedStrategy*>(s)->_pf_record_failure("run_backtest", "unknown C++ exception");')
1918
+ lines.append(' }')
1903
1919
  lines.append(" }")
1904
- lines.append(" void run_backtest_full(void* s, Bar* bars, int n,")
1920
+ lines.append(" static void _pf_run_backtest_full_impl(void* s, Bar* bars, int n,")
1905
1921
  lines.append(' const char* input_tf, const char* script_tf,')
1906
1922
  lines.append(" int bar_magnifier, int magnifier_samples,")
1907
1923
  lines.append(" int magnifier_dist,")
1908
1924
  lines.append(" ReportC* out) {")
1909
1925
  lines.append(' auto* strat = static_cast<GeneratedStrategy*>(s);')
1926
+ lines.append("#ifndef PF_SETTINGS_API_VERSION")
1927
+ lines.append(" if (strat->_pf_refuse_failed_setting(out)) return;")
1928
+ lines.append("#endif")
1910
1929
  lines.append(' std::string itf = input_tf ? input_tf : "";')
1911
1930
  lines.append(' std::string stf = script_tf ? script_tf : "";')
1912
1931
  if self._security_calls:
@@ -1943,26 +1962,42 @@ class TopLevelEmitter:
1943
1962
  lines.append(" strat->run(bars, n, itf, stf, bar_magnifier != 0, magnifier_samples,")
1944
1963
  lines.append(" static_cast<MagnifierDistribution>(magnifier_dist));")
1945
1964
  lines.append(" }")
1946
- lines.append(" strat->fill_report(out);")
1965
+ lines.append(" if (!strat->_pf_refuse_failed_setting(out)) strat->fill_report(out);")
1947
1966
  lines.append(" }")
1967
+ lines.extend([
1968
+ " void run_backtest_full(void* s, Bar* bars, int n, const char* input_tf, const char* script_tf,",
1969
+ " int bar_magnifier, int magnifier_samples, int magnifier_dist, ReportC* out) {",
1970
+ " try { _pf_run_backtest_full_impl(s, bars, n, input_tf, script_tf, bar_magnifier, magnifier_samples, magnifier_dist, out); }",
1971
+ ' catch (const std::exception& _pf_error) { if (out) *out = ReportC{}; if (s) static_cast<GeneratedStrategy*>(s)->_pf_record_failure("run_backtest_full", _pf_error.what()); }',
1972
+ ' catch (...) { if (out) *out = ReportC{}; if (s) static_cast<GeneratedStrategy*>(s)->_pf_record_failure("run_backtest_full", "unknown C++ exception"); }',
1973
+ " }",
1974
+ ])
1948
1975
  lines.append(" void strategy_free(void* s) {")
1949
- lines.append(" delete static_cast<GeneratedStrategy*>(s);")
1976
+ lines.append(" try { delete static_cast<GeneratedStrategy*>(s); } catch (...) {}")
1950
1977
  lines.append(" }")
1951
1978
  lines.append(" void report_free(ReportC* report) {")
1952
- lines.append(" BacktestEngine::free_report(report);")
1979
+ lines.append(" try { BacktestEngine::free_report(report); } catch (...) {}")
1953
1980
  lines.append(" }")
1954
1981
  lines.append(" void strategy_set_input(void* s, const char* key, const char* value) {")
1955
1982
  lines.append(" if (!s || !key || !value) return;")
1956
- lines.append(" static_cast<GeneratedStrategy*>(s)->set_input(key, value);")
1983
+ lines.append(" try { static_cast<GeneratedStrategy*>(s)->set_input(key, value); }")
1984
+ lines.append(' catch (const std::exception& _pf_error) { static_cast<GeneratedStrategy*>(s)->_pf_record_setting_failure("strategy_set_input", _pf_error.what()); }')
1985
+ lines.append(' catch (...) { static_cast<GeneratedStrategy*>(s)->_pf_record_setting_failure("strategy_set_input", "unknown C++ exception"); }')
1957
1986
  lines.append(" }")
1958
1987
  lines.append(" void strategy_set_override(void* s, const char* key, const char* value) {")
1959
1988
  lines.append(" if (!s || !key || !value) return;")
1960
- lines.append(" static_cast<GeneratedStrategy*>(s)->set_strategy_override(key, value);")
1989
+ lines.append(" try { static_cast<GeneratedStrategy*>(s)->set_strategy_override(key, value); }")
1990
+ lines.append(' catch (const std::exception& _pf_error) { static_cast<GeneratedStrategy*>(s)->_pf_record_setting_failure("strategy_set_override", _pf_error.what()); }')
1991
+ lines.append(' catch (...) { static_cast<GeneratedStrategy*>(s)->_pf_record_setting_failure("strategy_set_override", "unknown C++ exception"); }')
1961
1992
  lines.append(" }")
1962
1993
  lines.append(" void strategy_set_magnifier_volume_weighted(void* s, int on) {")
1963
1994
  lines.append(" if (!s) return;")
1964
- lines.append(" static_cast<GeneratedStrategy*>(s)->set_magnifier_volume_weighted(on != 0);")
1995
+ lines.append(" try { static_cast<GeneratedStrategy*>(s)->set_magnifier_volume_weighted(on != 0); }")
1996
+ lines.append(' catch (const std::exception& _pf_error) { static_cast<GeneratedStrategy*>(s)->_pf_record_setting_failure("strategy_set_magnifier_volume_weighted", _pf_error.what()); }')
1997
+ lines.append(' catch (...) { static_cast<GeneratedStrategy*>(s)->_pf_record_setting_failure("strategy_set_magnifier_volume_weighted", "unknown C++ exception"); }')
1965
1998
  lines.append(" }")
1999
+ from .checked_settings import emit_settings_exports
2000
+ emit_settings_exports(lines)
1966
2001
  if self._declares_bar_magnifier():
1967
2002
  # TradingView runs a script that declares use_bar_magnifier = true
1968
2003
  # on its bar magnifier; the host reads this export to run it on
@@ -314,7 +314,13 @@ CPP_EMITTER_NAMES = frozenset("""
314
314
  _pf_drawing_na _pf_collect_drawings _pf_collect_lines_ _pf_collect_boxes_
315
315
  _pf_collect_labels_ _pf_new _pf_held
316
316
  get_input_int get_input_float get_input_bool get_input_string
317
- trace is_na na nz fixnan
317
+ _pf_record_failure _pf_refuse_failed_setting _pf_settings_declared_config
318
+ _pf_record_setting_failure _pf_require_settings_ok
319
+ _pf_setting_failed_ _pf_setting_failure_
320
+ _pf_settings_inputs _pf_settings_overrides _pf_set_input_checked
321
+ _pf_set_override_checked _pf_settings_receipt
322
+ _pf_close_entries_rule_word _pf_default_qty_type_word _pf_commission_type_word
323
+ checked_settings trace is_na na nz fixnan
318
324
  """.split())
319
325
 
320
326
  # The generated history members, ``_<kind>_<n>`` (codegen/base.py
@@ -8,6 +8,7 @@ strategy class, so ``_safe_name`` renames it.
8
8
  """
9
9
 
10
10
  HOST_MEMBER_NAMES = frozenset({
11
+ "StreamPhase",
11
12
  "_src_close_",
12
13
  "_src_high_",
13
14
  "_src_hl2_",
@@ -50,6 +51,7 @@ HOST_MEMBER_NAMES = frozenset({
50
51
  "closed_trade_profit_percent",
51
52
  "closed_trade_size",
52
53
  "commit_script_state",
54
+ "config_",
53
55
  "configure_pine_strategy",
54
56
  "configure_security_evaluators",
55
57
  "count_losstrades",
@@ -74,9 +76,12 @@ HOST_MEMBER_NAMES = frozenset({
74
76
  "history_advances_new_bar",
75
77
  "initial_capital_",
76
78
  "input_tf_",
79
+ "inputs_",
77
80
  "is_first_tick",
78
81
  "is_last_tick_",
79
82
  "last_bar_time_",
83
+ "last_error",
84
+ "last_error_",
80
85
  "main_period",
81
86
  "margin_liquidation_price",
82
87
  "max_contracts_held_all",
@@ -104,6 +109,7 @@ HOST_MEMBER_NAMES = frozenset({
104
109
  "open_trade_profit_percent",
105
110
  "open_trade_size",
106
111
  "open_trades_capital_held",
112
+ "override_",
107
113
  "pine_bar_index",
108
114
  "pine_last_bar_index",
109
115
  "pine_session_ismarket",
@@ -123,6 +129,7 @@ HOST_MEMBER_NAMES = frozenset({
123
129
  "restore_script_state",
124
130
  "round_to_mintick",
125
131
  "run",
132
+ "script_bars_processed",
126
133
  "script_tf_",
127
134
  "security_column_value",
128
135
  "security_eval_states_",
@@ -151,6 +158,7 @@ HOST_MEMBER_NAMES = frozenset({
151
158
  "strategy_exit",
152
159
  "strategy_exit_cancel_bracket",
153
160
  "strategy_order",
161
+ "stream_phase_",
154
162
  "symbol_facts_",
155
163
  "symbol_feeds_",
156
164
  "syminfo_",
@@ -85,14 +85,43 @@ class InputHelper:
85
85
  while merged and merged[-1] is None:
86
86
  merged.pop()
87
87
  if merged and merged[0] is not None:
88
- return merged[0]
88
+ return self._fold_enum_input_default(node, merged[0])
89
89
  return None
90
90
  if node.args:
91
- return node.args[0]
91
+ return self._fold_enum_input_default(node, node.args[0])
92
92
  if "defval" in node.kwargs:
93
- return node.kwargs["defval"]
93
+ return self._fold_enum_input_default(node, node.kwargs["defval"])
94
94
  return None
95
95
 
96
+ def _fold_enum_input_default(self, node: FuncCall, default):
97
+ """Resolve an enum defval's immutable, source-ordered initializer."""
98
+ func_name, namespace = self._resolve_callee(node.callee)
99
+ if namespace != "input" or func_name != "enum" or not isinstance(default, Identifier):
100
+ return default
101
+ declarations = {}
102
+ reassigned = self._find_reassigned_vars()
103
+ for statement in self.ctx.ast.body:
104
+ if any(candidate is node for candidate in self._walk_ast(statement)):
105
+ break
106
+ if isinstance(statement, VarDecl):
107
+ declarations.setdefault(statement.name, []).append(statement)
108
+ candidate = default
109
+ visited = set()
110
+ while isinstance(candidate, Identifier) and candidate.name not in visited:
111
+ visited.add(candidate.name)
112
+ bindings = declarations.get(candidate.name, [])
113
+ if len(bindings) != 1 or candidate.name in reassigned:
114
+ return default
115
+ declaration = bindings[0]
116
+ if declaration.is_var or declaration.is_varip:
117
+ return default
118
+ candidate = declaration.value
119
+ if isinstance(candidate, MemberAccess) and isinstance(candidate.object, Identifier):
120
+ members = self._enum_defs.get(candidate.object.name, [])
121
+ if candidate.member in members:
122
+ return candidate
123
+ return default
124
+
96
125
  def _input_default_value(self, node: FuncCall) -> tuple[bool, object]:
97
126
  """``(True, value)`` when the defval is the constant a ``v = input...``
98
127
  binding records as ``v``'s known value -- a number, bool or string
@@ -527,6 +556,10 @@ class InputHelper:
527
556
  "type": form_type,
528
557
  "default": default_val,
529
558
  }
559
+ if namespace == "input" and func_name == "symbol":
560
+ # A symbol string, which the type does not tell from a string's:
561
+ # the requests of another symbol key their feeds on its value.
562
+ entry["kind"] = "symbol"
530
563
  # Pull min/max/step/options by signature param name; emit only
531
564
  # const literals so the override form never references a runtime
532
565
  # value it can't reproduce.
@@ -858,6 +858,9 @@ def unpin_requests(program: Program, reasons: dict[int, str]) -> None:
858
858
  lowered.annotations = {**(lowered.annotations or {}),
859
859
  UNPINNED_ANNOTATION: marker["message"]}
860
860
  swaps[request_id] = lowered
861
+ # The request leaves the program; its lowering is the deferred
862
+ # refusal's now (``request_discovery`` lists it so).
863
+ request.annotations = {**(request.annotations or {}), LOWERING_ANNOTATION: "unpinned"}
861
864
  warnings.append(pass_warning(
862
865
  request,
863
866
  f"{spell_call(request)}: no data is pinned for this request; the run stops with "
@@ -0,0 +1,506 @@
1
+ """The other symbols' data a script requests, listed before it runs.
2
+
3
+ ``transpile_full(...)["requests"]`` names every request site that reads
4
+ another symbol's feed, so a host can fetch the bars a run needs before it
5
+ starts it. A run supplies another symbol's bars as one feed per (symbol
6
+ string, timeframe) and the engine looks a site's feed up byte for byte
7
+ (``codegen/emit_top.py``, ``_emit_foreign_security_registration``), so each
8
+ entry states both the way registration computes them:
9
+
10
+ {"line": 7, "fn": "request.security",
11
+ "symbol": {"kind": "input", "title": "Other symbol", "default": "BINANCE:ETHUSDT"},
12
+ "timeframe": {"kind": "chart"},
13
+ "lookahead": false, "gaps": false, "ignore_invalid_symbol": false}
14
+
15
+ The sites are the registrations the C++ makes: ``request.security`` of
16
+ another symbol that the support checker lowered onto its feed
17
+ (``external_requests.FEED_LOWERING``), one entry per context a helper's call
18
+ paths give it (``security_contexts``), with the feed column a footprint reads
19
+ (``column``). A site whose value reaches display and alert sinks only is
20
+ lowered to ``na`` and reads nothing, and a helper nothing reaches never runs:
21
+ neither is listed. A deferred refusal -- another symbol's request whose value
22
+ can reach a trade but which registration cannot key before the first bar --
23
+ is listed with an ``unresolvable`` symbol: its first read stops the run.
24
+ Every other request reads no feed: the chart's symbol, ``request.*`` of
25
+ fundamentals (recorded series, not bars) and ``request.security_lower_tf`` of
26
+ another symbol (it stops the run where it is evaluated).
27
+
28
+ Names are expanded as registration expands them
29
+ (``_substitute_tf_input_reads``: the expression the C++ renders and the run
30
+ evaluates before the first bar), and a ``value`` is computed only over
31
+ ``ScriptIndex.registration_value``'s grammar -- literals, inputs at their
32
+ defaults, ``+ == != and or not``, ternaries and ``ticker.inherit`` /
33
+ ``standard`` -- so discovery states a value where the run computes the same
34
+ one from the same expression.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from .ast_nodes import (
40
+ BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, NaLiteral,
41
+ NumberLiteral, StringLiteral, Subscript, SwitchStmt, Ternary, UnaryOp,
42
+ )
43
+ from .errors import CompileError, Phase
44
+ from .external_requests import FEED_LOWERING, LOWERING_ANNOTATION, _nodes
45
+ from .pine_spelling import is_input_call, pine_string_literal
46
+ from .security_contexts import _REGISTRATION_INPUTS, ScriptIndex, _reached, ticker_symbol_arg
47
+
48
+ # A deferred refusal's lowering (``support_checker._lower_no_data_request``,
49
+ # ``external_requests.unpin_requests``).
50
+ _UNPINNED_LOWERING = "unpinned"
51
+ # The chart's own timeframe where a fold meets ``timeframe.period``.
52
+ _CHART = object()
53
+ _UNKNOWN = object()
54
+ # A folded value longer than any symbol or timeframe is no key.
55
+ _MAX_VALUE_CHARS = 1024
56
+ # Names a fold follows, one through another, and nodes it visits, before it
57
+ # gives up (the value is then unknown).
58
+ _MAX_FOLD_DEPTH = 256
59
+ _MAX_FOLD_STEPS = 100_000
60
+
61
+
62
+ class _Fold:
63
+ """One value's fold: each name expanded and folded once."""
64
+
65
+ def __init__(self, gen) -> None:
66
+ self.gen = gen
67
+ self.names: dict[str, object] = {}
68
+ self.steps = 0
69
+ _PRECEDENCE = {"or": 1, "and": 2, "==": 3, "!=": 3, "<": 4, "<=": 4, ">": 4, ">=": 4,
70
+ "+": 5, "-": 5, "*": 6, "/": 6, "%": 6}
71
+
72
+
73
+ def request_sites(program) -> list[FuncCall]:
74
+ """The ``request.security`` calls the support checker lowered onto
75
+ another symbol's feed or to a deferred refusal, outside the helpers
76
+ nothing reaches: taken before the passes that replace a refusal by its
77
+ ``na`` (``lower_no_data_requests``) or turn a feed back into one
78
+ (``unpin_requests``), which mark it so."""
79
+ sites = [node for node in _nodes(program)
80
+ if isinstance(node, FuncCall) and _call_name(node) == ("request", "security")
81
+ and (node.annotations or {}).get(LOWERING_ANNOTATION)
82
+ in (FEED_LOWERING, _UNPINNED_LOWERING)]
83
+ if not sites:
84
+ return sites
85
+ index = ScriptIndex(program)
86
+ reached = _reached(index)
87
+ return [site for site in sites
88
+ if (owner := index.owner.get(id(site))) not in index.funcs or owner in reached]
89
+
90
+
91
+ def discover_requests(gen, ctx, sites: list[FuncCall]) -> list[dict]:
92
+ """The requests of another symbol's data (module docstring), by line,
93
+ each distinct entry once."""
94
+ entries: list[tuple[tuple, dict]] = []
95
+ calls = {call.sec_id: call for call in ctx.security_calls}
96
+ mutable_reads = set(gen._security_tf_mutable_reads)
97
+
98
+ def add(loc, entry: dict) -> None:
99
+ entry = {"line": loc.line if loc is not None else None, "fn": "request.security",
100
+ **entry}
101
+ where = (loc.line or 0, loc.col or 0) if loc is not None else (0, 0)
102
+ entries.append(((*where, len(entries)), entry))
103
+
104
+ budget = getattr(gen, "_budget", None)
105
+ foreign = [info for info in gen._security_eval_info if info.get("foreign")]
106
+ helpers: set[str] = set()
107
+ unreached: set[str] = set()
108
+ if any(getattr(calls.get(info["sec_id"]), "containing_func", "") for info in foreign):
109
+ # A helper nothing reaches never runs: its registration is never read.
110
+ index = ScriptIndex(ctx.ast)
111
+ helpers = set(index.funcs)
112
+ unreached = helpers - _reached(index)
113
+ try:
114
+ for info in foreign:
115
+ helper = getattr(calls.get(info["sec_id"]), "containing_func", "")
116
+ if helper in helpers and helper in unreached:
117
+ continue
118
+ # Past the time budget the rest are listed as written, unexpanded:
119
+ # discovery never fails a script that transpiled.
120
+ expand = _within(budget)
121
+ entry = {
122
+ "symbol": _guarded(_symbol, gen, info["symbol_node"], expand=expand),
123
+ "timeframe": _guarded(_registered_timeframe, gen, info, expand=expand),
124
+ "lookahead": bool(info.get("lookahead_on")),
125
+ "gaps": bool(info.get("gaps_on")),
126
+ "ignore_invalid_symbol": _guarded(
127
+ _flag, gen, info.get("ignore_invalid_node"), expand=expand),
128
+ }
129
+ column = gen._security_footprint_column(info["sec_id"])
130
+ if column:
131
+ entry["column"] = column
132
+ add(getattr(calls.get(info["sec_id"]), "loc", None), entry)
133
+ for site in sites:
134
+ if (site.annotations or {}).get(LOWERING_ANNOTATION) != _UNPINNED_LOWERING:
135
+ continue
136
+ # The C++ never registered it, so nothing expands its names here:
137
+ # its arguments are listed as written.
138
+ symbol, timeframe = _request_args(site)
139
+ gaps, lookahead = _gaps_lookahead(site)
140
+ ignore = site.kwargs.get("ignore_invalid_symbol")
141
+ add(site.loc, {
142
+ "symbol": {"kind": "unresolvable", "expr": _spell(symbol)},
143
+ "timeframe": _guarded(_timeframe, gen, timeframe, expand=False),
144
+ "lookahead": _barmerge(lookahead, "lookahead_on"),
145
+ "gaps": _barmerge(gaps, "gaps_on"),
146
+ "ignore_invalid_symbol": (
147
+ False if ignore is None else
148
+ ignore.value if isinstance(ignore, BoolLiteral) else None),
149
+ })
150
+ finally:
151
+ # Expanding a name registers a reassigned global's read with the
152
+ # first-bar replay; generation is over, so leave its record as it was.
153
+ gen._security_tf_mutable_reads = mutable_reads
154
+ out: list[dict] = []
155
+ for _, entry in sorted(entries, key=lambda item: item[0]):
156
+ if entry not in out:
157
+ out.append(entry)
158
+ return out
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Symbol and timeframe
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def _within(budget) -> bool:
166
+ """Whether the transpile's time budget still holds."""
167
+ if budget is None:
168
+ return True
169
+ try:
170
+ budget.check(phase=Phase.CODEGEN)
171
+ except CompileError:
172
+ return False
173
+ return True
174
+
175
+
176
+ def _guarded(classify, gen, node, **options):
177
+ """``classify(gen, node)``; a shape it does not know is listed as
178
+ computed, with no value: discovery never fails a script that
179
+ transpiled."""
180
+ try:
181
+ return classify(gen, node, **options)
182
+ except Exception: # noqa: BLE001
183
+ if isinstance(node, dict):
184
+ node = node.get("tf_node")
185
+ return None if classify is _flag else {"kind": "computed", "expr": _spell(node)}
186
+
187
+
188
+ def _symbol(gen, node, expand: bool = True) -> dict:
189
+ """``literal`` / ``input`` / ``computed`` as registration computes the
190
+ symbol string the run keys its feed on."""
191
+ resolved = _passthrough(_expand(gen, node) if expand else node)
192
+ if isinstance(resolved, StringLiteral):
193
+ return {"kind": "literal", "value": resolved.value}
194
+ if _is_registered_input(resolved):
195
+ return _input(gen, resolved)
196
+ if not expand:
197
+ return {"kind": "computed", "expr": _spell(node)}
198
+ return _computed(gen, node, resolved, timeframe=False)
199
+
200
+
201
+ def _registered_timeframe(gen, info: dict, expand: bool = True) -> dict:
202
+ """The timeframe a feed site registers with (``_resolve_security_tf``'s
203
+ ``tf`` / ``tf_expr``, ``configure_security_evaluators``). ``input_tf_``
204
+ is the chart's: a run reading another symbol's feed takes the chart's
205
+ bars unaggregated (the engine refuses it otherwise)."""
206
+ tf, tf_expr = info.get("tf"), info.get("tf_expr")
207
+ if tf:
208
+ return {"kind": "literal", "value": canonical_timeframe(tf)}
209
+ if tf == "" or tf_expr in ("input_tf_", "script_tf_"):
210
+ return {"kind": "chart"}
211
+ return _timeframe(gen, info.get("tf_node"), expand=expand)
212
+
213
+
214
+ def _timeframe(gen, node, expand: bool = True) -> dict:
215
+ """A timeframe registration expands (``expand``), or as written."""
216
+ if node is None:
217
+ return {"kind": "chart"}
218
+ resolved = _expand(gen, node) if expand else node
219
+ if isinstance(resolved, StringLiteral):
220
+ if resolved.value == "":
221
+ return {"kind": "chart"}
222
+ return {"kind": "literal", "value": canonical_timeframe(resolved.value)}
223
+ if _is_timeframe_period(resolved):
224
+ return {"kind": "chart"}
225
+ if _is_registered_input(resolved, timeframe=True):
226
+ return _input(gen, resolved)
227
+ if not expand:
228
+ return {"kind": "computed", "expr": _spell(node)}
229
+ return _computed(gen, node, resolved, timeframe=True)
230
+
231
+
232
+ def canonical_timeframe(tf: str) -> str:
233
+ """The engine's spelling of a feed's timeframe
234
+ (``canonical_symbol_timeframe``): Pine's bare ``D`` / ``W`` / ``M`` /
235
+ ``S`` folded to ``1D`` / ``1W`` / ``1M`` / ``1S``, any other text kept
236
+ as it is (whole minutes ``"240"``, ``<n>D|W|M|S``; a feed matches no
237
+ other spelling)."""
238
+ return "1" + tf if tf in ("D", "W", "M", "S") else tf
239
+
240
+
241
+ def _input(gen, call: FuncCall) -> dict:
242
+ default = _input_default(gen, call)
243
+ return {"kind": "input", "title": gen._get_input_title(call),
244
+ "default": None if default is _UNKNOWN else default}
245
+
246
+
247
+ def _computed(gen, node, resolved, *, timeframe: bool) -> dict:
248
+ out = {"kind": "computed", "expr": _spell(node)}
249
+ value = _fold(_Fold(gen), resolved)
250
+ if value is _CHART and timeframe:
251
+ out["value"] = ""
252
+ elif isinstance(value, str):
253
+ out["value"] = canonical_timeframe(value) if timeframe and value else value
254
+ titles: list[str] = []
255
+ for call in _input_calls(gen, resolved):
256
+ title = gen._get_input_title(call)
257
+ if title not in titles:
258
+ titles.append(title)
259
+ if titles:
260
+ out["inputs"] = titles
261
+ return out
262
+
263
+
264
+ def _flag(gen, node, expand: bool = True) -> bool | None:
265
+ """``ignore_invalid_symbol`` as registration reads it: false when left
266
+ out, else its value at the inputs' defaults (None when unknown)."""
267
+ if node is None:
268
+ return False
269
+ if not expand:
270
+ return node.value if isinstance(node, BoolLiteral) else None
271
+ value = _fold(_Fold(gen), _expand(gen, node))
272
+ return value if isinstance(value, bool) else None
273
+
274
+
275
+ def _expand(gen, node):
276
+ """``node`` with its names expanded as registration expands them."""
277
+ return gen._substitute_tf_input_reads(node, set())
278
+
279
+
280
+ def _passthrough(node):
281
+ """``ticker.inherit`` / ``ticker.standard`` render their symbol argument
282
+ unchanged (``visit_call``)."""
283
+ while (isinstance(node, FuncCall) and _call_name(node) in (
284
+ ("ticker", "inherit"), ("ticker", "standard"))):
285
+ symbol = ticker_symbol_arg(node)
286
+ if symbol is None:
287
+ break
288
+ node = symbol
289
+ return node
290
+
291
+
292
+ def _is_registered_input(node, timeframe: bool = False) -> bool:
293
+ """An input whose value registration reads: ``input.symbol`` /
294
+ ``string`` (``timeframe`` for a timeframe), or ``input()`` of a string."""
295
+ if not is_input_call(node):
296
+ return False
297
+ ns, name = _call_name(node)
298
+ if ns is None:
299
+ default = node.args[0] if node.args else node.kwargs.get("defval")
300
+ return isinstance(default, StringLiteral)
301
+ return name in (_REGISTRATION_INPUTS - {"bool"}) or (timeframe and name == "timeframe")
302
+
303
+
304
+ def _input_default(gen, call: FuncCall):
305
+ default = gen._get_input_default(call)
306
+ if isinstance(default, (StringLiteral, BoolLiteral, NumberLiteral)):
307
+ return default.value
308
+ return _UNKNOWN
309
+
310
+
311
+ def _input_calls(gen, node) -> list[FuncCall]:
312
+ """The input calls ``node`` reads, through the names registration
313
+ expands (a ``switch``'s too), in source order."""
314
+ found: dict[int, FuncCall] = {}
315
+ seen: set[str] = set()
316
+ pending = [node]
317
+ while pending:
318
+ for item in _nodes(pending.pop()):
319
+ if is_input_call(item):
320
+ found.setdefault(id(item), item)
321
+ elif isinstance(item, Identifier) and item.name not in seen:
322
+ seen.add(item.name)
323
+ expanded = _expand(gen, item)
324
+ if expanded is not item:
325
+ pending.append(expanded)
326
+ return sorted(found.values(), key=lambda call: (call.loc.line, call.loc.col)
327
+ if call.loc is not None else (0, 0))
328
+
329
+
330
+ def _fold(state: _Fold, node, depth: int = 0):
331
+ """The value at the inputs' defaults of an expression in
332
+ ``ScriptIndex.registration_value``'s grammar (and, for a timeframe, a
333
+ ``switch`` of single-expression arms, as registration renders it), else
334
+ ``_UNKNOWN``. The chart's own symbol strings are the chart's (unknown
335
+ here); ``timeframe.period`` is ``_CHART``."""
336
+ state.steps += 1
337
+ if depth > _MAX_FOLD_DEPTH or state.steps > _MAX_FOLD_STEPS:
338
+ return _UNKNOWN
339
+ depth += 1
340
+ if isinstance(node, (StringLiteral, BoolLiteral)):
341
+ return node.value
342
+ if isinstance(node, NumberLiteral):
343
+ return node.value
344
+ if _is_timeframe_period(node):
345
+ return _CHART
346
+ if isinstance(node, Identifier):
347
+ # A name registration expands (a switch's subject: the expansion
348
+ # leaves a switch as written); one it keeps has no value here.
349
+ if node.name not in state.names:
350
+ state.names[node.name] = _UNKNOWN # a name read inside its own value
351
+ expanded = _expand(state.gen, node)
352
+ state.names[node.name] = (_UNKNOWN if expanded is node
353
+ else _fold(state, expanded, depth))
354
+ return state.names[node.name]
355
+ if isinstance(node, SwitchStmt):
356
+ return _fold_switch(state, node, depth)
357
+ if isinstance(node, FuncCall):
358
+ if is_input_call(node):
359
+ ns, name = _call_name(node)
360
+ if ns is None or name in _REGISTRATION_INPUTS or name == "timeframe":
361
+ return _input_default(state.gen, node)
362
+ return _UNKNOWN
363
+ passed = _passthrough(node)
364
+ return _UNKNOWN if passed is node else _fold(state, passed, depth)
365
+ if isinstance(node, Ternary):
366
+ condition = _fold(state, node.condition, depth)
367
+ if not isinstance(condition, bool):
368
+ return _UNKNOWN
369
+ return _fold(state, node.true_val if condition else node.false_val, depth)
370
+ if isinstance(node, UnaryOp):
371
+ operand = _fold(state, node.operand, depth)
372
+ return (not operand) if node.op == "not" and isinstance(operand, bool) else _UNKNOWN
373
+ if isinstance(node, BinOp) and node.op in ("+", "==", "!=", "and", "or"):
374
+ left = _fold(state, node.left, depth)
375
+ if left is _UNKNOWN or left is _CHART:
376
+ return _UNKNOWN
377
+ right = _fold(state, node.right, depth)
378
+ if right is _UNKNOWN or right is _CHART:
379
+ return _UNKNOWN
380
+ if node.op == "+":
381
+ if not (isinstance(left, str) and isinstance(right, str)
382
+ and len(left) + len(right) <= _MAX_VALUE_CHARS):
383
+ return _UNKNOWN
384
+ return left + right
385
+ if node.op in ("and", "or"):
386
+ if not (isinstance(left, bool) and isinstance(right, bool)):
387
+ return _UNKNOWN
388
+ return (left and right) if node.op == "and" else (left or right)
389
+ if type(left) is not type(right):
390
+ return _UNKNOWN
391
+ return (left == right) if node.op == "==" else (left != right)
392
+ return _UNKNOWN
393
+
394
+
395
+ def _fold_switch(state: _Fold, node: SwitchStmt, depth: int):
396
+ """A timeframe ``switch`` as registration renders it
397
+ (``_security_tf_switch_runtime_expr``): the first arm whose value equals
398
+ the subject (or whose condition holds), else the default arm, else the
399
+ chart's timeframe; every arm one expression."""
400
+ subject = None
401
+ if node.expr is not None:
402
+ subject = _fold(state, node.expr, depth)
403
+ if subject is _UNKNOWN or subject is _CHART:
404
+ return _UNKNOWN
405
+
406
+ def arm(body):
407
+ if len(body) != 1 or not isinstance(body[0], ExprStmt) or isinstance(
408
+ body[0].expr, NaLiteral):
409
+ return _UNKNOWN
410
+ return _fold(state, body[0].expr, depth)
411
+
412
+ default = node.default_body or None
413
+ for condition, body in node.cases:
414
+ if condition is None:
415
+ default = default or body
416
+ continue
417
+ value = _fold(state, condition, depth)
418
+ if value is _UNKNOWN or value is _CHART:
419
+ return _UNKNOWN
420
+ if (value is True if node.expr is None
421
+ else type(value) is type(subject) and value == subject):
422
+ return arm(body)
423
+ return arm(default) if default else _CHART
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Call shapes and spelling
428
+ # ---------------------------------------------------------------------------
429
+
430
+ def _call_name(node: FuncCall) -> tuple[str | None, str | None]:
431
+ callee = node.callee
432
+ if isinstance(callee, Identifier):
433
+ return None, callee.name
434
+ if isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier):
435
+ return callee.object.name, callee.member
436
+ return None, None
437
+
438
+
439
+ def _is_timeframe_period(node) -> bool:
440
+ """``timeframe.period`` or ``timeframe.main_period``: the chart's."""
441
+ return (isinstance(node, MemberAccess) and node.member in ("period", "main_period")
442
+ and isinstance(node.object, Identifier) and node.object.name == "timeframe")
443
+
444
+
445
+ def _request_args(node: FuncCall):
446
+ symbol = node.args[0] if node.args else node.kwargs.get("symbol")
447
+ timeframe = node.args[1] if len(node.args) > 1 else node.kwargs.get("timeframe")
448
+ return symbol, timeframe
449
+
450
+
451
+ def _gaps_lookahead(node: FuncCall):
452
+ gaps = node.kwargs.get("gaps")
453
+ if gaps is None and len(node.args) > 3:
454
+ gaps = node.args[3]
455
+ lookahead = node.kwargs.get("lookahead")
456
+ if lookahead is None and len(node.args) > 4:
457
+ lookahead = node.args[4]
458
+ return gaps, lookahead
459
+
460
+
461
+ def _barmerge(node, on: str) -> bool | None:
462
+ """A ``barmerge.*`` constant's flag; left out is off; None when computed."""
463
+ if node is None:
464
+ return False
465
+ if (isinstance(node, MemberAccess) and isinstance(node.object, Identifier)
466
+ and node.object.name == "barmerge"):
467
+ return node.member == on
468
+ return None
469
+
470
+
471
+ def _spell(node, parent: int = 0, right: bool = False) -> str:
472
+ """``node`` spelled as Pine source (display only)."""
473
+ if node is None:
474
+ return ""
475
+ if isinstance(node, StringLiteral):
476
+ return pine_string_literal(node.value)
477
+ if isinstance(node, BoolLiteral):
478
+ return "true" if node.value else "false"
479
+ if isinstance(node, NumberLiteral):
480
+ return str(node.value)
481
+ if isinstance(node, NaLiteral):
482
+ return "na"
483
+ if isinstance(node, Identifier):
484
+ return node.name
485
+ if isinstance(node, MemberAccess):
486
+ return f"{_spell(node.object, 9)}.{node.member}"
487
+ if isinstance(node, FuncCall):
488
+ args = [_spell(a) for a in node.args]
489
+ args += [f"{key} = {_spell(value)}" for key, value in node.kwargs.items()]
490
+ return f"{_spell(node.callee, 9)}({', '.join(args)})"
491
+ if isinstance(node, Subscript):
492
+ return f"{_spell(node.object, 9)}[{_spell(node.index)}]"
493
+ if isinstance(node, UnaryOp):
494
+ text = f"not {_spell(node.operand, 7)}" if node.op == "not" else (
495
+ f"{node.op}{_spell(node.operand, 7)}")
496
+ return f"({text})" if parent > 7 else text
497
+ if isinstance(node, BinOp):
498
+ level = _PRECEDENCE.get(node.op, 5)
499
+ text = (f"{_spell(node.left, level)} {node.op} "
500
+ f"{_spell(node.right, level, right=True)}")
501
+ return f"({text})" if level < parent or (right and level == parent) else text
502
+ if isinstance(node, Ternary):
503
+ text = (f"{_spell(node.condition, 1)} ? {_spell(node.true_val)} : "
504
+ f"{_spell(node.false_val)}")
505
+ return f"({text})" if parent else text
506
+ return "..."
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "1.0.1",
2
+ "codegen": "1.1.0",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "2ad9a8595850ce2c485dcddb049fd738097d9149d2136ce83a951a506541a113"
6
+ "sha256": "37b1ca85a2006f6f07d52074a7180187536047bfbb857681978a372b1d7f9bef"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "1.0.1",
2
+ "CODEGEN_VERSION": "1.1.0",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
@@ -49,7 +49,8 @@ def transpile_json(source: str) -> str:
49
49
  # A script that transpiled carries its warnings in the same entry format.
50
50
  return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"],
51
51
  "strategyParams": full["strategyParams"],
52
- "diagnostics": _diagnostic_entries(full["diagnostics"])})
52
+ "diagnostics": _diagnostic_entries(full["diagnostics"]),
53
+ "requests": full["requests"]})
53
54
  `;
54
55
 
55
56
  const post = (m) => self.postMessage(m);
Binary file