diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index 2c238ad..c362cb8 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -514,6 +514,15 @@ def func_var_storage(owner: str, raw_name: str) -> str: # the declaration-derived checkpoint inventory. self._inline_history_members: list[dict] = [] self._inline_history_member_by_key: dict[tuple, str] = {} + # request.security/request.security_lower_tf inline their Pine helper + # expressions on the requested clock instead of calling the ordinary + # emitted UDF methods. The chart-clocked plain-UDF history factor must + # therefore exclude those source nodes and any emitted helper context + # reached only through them. + self._requested_context_inline_node_ids: set[int] = set() + self._requested_context_only_inline_contexts: set[ + tuple[str, str | None] + ] = set() # Unique lambda-local names used when an array lowering references its # receiver more than once. The binding keeps temporary-producing or # side-effectful receivers single-evaluation (see TypeInferer). @@ -3173,7 +3182,11 @@ def _prepare_inline_history_members(self) -> None: """ self._inline_history_members = [] self._inline_history_member_by_key = {} - counters = {"hist_call": 0, "series_arg": 0} + counters = { + "hist_call": 0, + "series_arg": 0, + "udf_series_arg": 0, + } def walk_nodes(value): """Yield AST nodes in stable field order, including tuple elements. @@ -3345,6 +3358,113 @@ def owner_lexical_specs(owner: str | None) -> dict[str, TypeSpec | None]: lexical.update(self._func_collection_types.get(owner, {})) return lexical + def plain_udf_info(call: FuncCall): + if not isinstance(call.callee, Identifier): + return None + candidate = self._func_info_map.get(call.callee.name) + if ( + candidate is None + or candidate.node is None + or getattr(candidate, "is_udt_method", False) + ): + return None + return candidate + + # Requested-context UDF expressions are lowered independently by + # security.py. Classify their otherwise-emitted helper variants so + # this chart-clocked factor neither allocates unused buffers for them + # nor changes the legacy bodies that remain in generated C++. + requested_node_ids: set[int] = set() + for security_call in self._security_calls: + expression = security_call.get("expr_node") + if expression is None: + continue + requested_node_ids.update( + id(child) for child in walk_nodes(expression) + ) + self._requested_context_inline_node_ids = requested_node_ids + + def emitted_context_for_call( + fi, + call: FuncCall, + owner: str | None, + owner_context: str | None, + ) -> str | None: + dispatch = self._instance_dispatch.get( + (owner_context, id(call)) + ) + if dispatch is not None: + return dispatch + target_cs = target_cs_for_context( + fi, call, owner, owner_context + ) + if target_cs is not None: + return f"{self._func_cpp_base_name(fi.name)}_cs{target_cs}" + return None + + requested_roots: set[tuple[str, str | None]] = set() + chart_roots: set[tuple[str, str | None]] = set() + context_edges: dict[ + tuple[str, str | None], set[tuple[str, str | None]] + ] = {} + for call in ( + candidate + for candidate in walk_nodes(self.ctx.ast) + if isinstance(candidate, FuncCall) + ): + fi = plain_udf_info(call) + if fi is None: + continue + owner = owner_by_node.get(id(call)) + if id(call) in requested_node_ids: + owner_contexts = ( + [None] + if owner is None + else self._inline_history_contexts_for_owner(owner) + ) + for owner_context in owner_contexts: + requested_roots.add(( + fi.name, + emitted_context_for_call( + fi, call, owner, owner_context + ), + )) + continue + if owner is None: + target = ( + fi.name, + emitted_context_for_call(fi, call, None, None), + ) + chart_roots.add(target) + continue + for owner_context in self._inline_history_contexts_for_owner(owner): + source = (owner, owner_context) + target = ( + fi.name, + emitted_context_for_call( + fi, call, owner, owner_context + ), + ) + context_edges.setdefault(source, set()).add(target) + + def reachable( + roots: set[tuple[str, str | None]], + ) -> set[tuple[str, str | None]]: + found = set(roots) + pending = list(roots) + while pending: + source = pending.pop() + for target in context_edges.get(source, ()): + if target in found: + continue + found.add(target) + pending.append(target) + return found + + self._requested_context_only_inline_contexts = ( + reachable(requested_roots) - reachable(chart_roots) + ) + for node in walk_nodes(self.ctx.ast): owner = owner_by_node.get(id(node)) if isinstance(node, Subscript) and isinstance(node.object, FuncCall): @@ -3403,6 +3523,26 @@ def owner_lexical_specs(owner: str | None) -> dict[str, TypeSpec | None]: expected_cpp_type = self._series_param_element_cpp_type( fi, idx, target_cs ) + # Pine gives each plain-UDF written call its own + # chart-aligned parameter history. Even an exact Series + # actual therefore needs a call-site-owned buffer: skipped + # evaluations hold the last value in that bar's slot + # instead of exposing the caller's independently changing + # history. Typed methods retain their established direct + # binding/bridge behavior below. + chart_execution_context = ( + id(node) not in requested_node_ids + and (owner, context) + not in self._requested_context_only_inline_contexts + ) + if not method_call and chart_execution_context: + register_one( + "udf_series_arg", + (id(node), idx), + expected_cpp_type, + context, + ) + continue needs_bridge = True if isinstance(arg, Identifier): if ( diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index ec132de..39b09f2 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -949,6 +949,21 @@ def _emit_on_bar(self, lines: list[str]) -> None: f"{info['member_name']}.clear();" ) + # A history-reading plain-UDF parameter advances on the chart clock, + # even when lazy control flow skips its written call on this bar. Seed + # the new slot with the prior current value (``na`` before first reach); + # an executed call later in the bar updates this same slot with its + # scalar actual. Typed-method ``series_arg`` bridges intentionally keep + # their existing execution-clock behavior. + for info in self._inline_history_members: + if info["kind"] != "udf_series_arg": + continue + member = info["member_name"] + lines.append( + f" if (history_advances_new_bar()) " + f"{member}.push({member}.current());" + ) + # a. Push bar field series (with bar magnifier support) for field_name in sorted(self.ctx.series_bar_fields): push_expr = BAR_SERIES_PUSH.get(field_name, f"current_bar_.{field_name}") diff --git a/pineforge_codegen/codegen/visit_call.py b/pineforge_codegen/codegen/visit_call.py index a92afe5..3e042fc 100644 --- a/pineforge_codegen/codegen/visit_call.py +++ b/pineforge_codegen/codegen/visit_call.py @@ -2145,25 +2145,25 @@ def _visit_arg_for_series(arg_node, arg_idx): arg_idx, self._callable_target_callsite_idx(fi_lookup, node), ) - if isinstance(arg_node, Identifier): + chart_execution_context = ( + id(node) not in self._requested_context_inline_node_ids + and ( + getattr(self, "_active_func_name", None), + self._current_instance_name, + ) not in self._requested_context_only_inline_contexts + ) + if not chart_execution_context and isinstance(arg_node, Identifier): + # request.security/request.security_lower_tf own a separate + # evaluator clock and inline their UDF expressions in + # security.py. Preserve the pre-factor direct-Series + # lowering in the otherwise-emitted helper bodies. aname = arg_node.name - # Bar field: pass _s_close instead of current_bar_.close if ( (aname in BAR_FIELDS or aname in BAR_SERIES_PUSH) and expected_cpp_type == "double" ): return f"_s_{aname}" - # Exact Series binding: pass the Series object directly. - # A raw name can also denote a scalar sibling/callable - # shadow, in which case lexical state must override the - # legacy ``ctx.series_vars`` union and fall through to the - # synthetic history bridge below. safe = self._safe_name(aname) - # Function parameters are lexical C++ arguments in every - # emitted variant. The legacy function-series clone table - # can contain the same raw name, but applying it here makes - # cs1+ ignore the actual parameter and read an unrelated - # class member instead. is_current_series_param = ( aname in self._current_func_series_params ) @@ -2188,13 +2188,31 @@ def _visit_arg_for_series(arg_node, arg_idx): expr_cpp, cpp_t ) member = self._inline_history_member( - "series_arg", node, arg_idx=arg_idx + ( + "udf_series_arg" + if chart_execution_context + else "series_arg" + ), + node, + arg_idx=arg_idx, ) + if not chart_execution_context: + return ( + f"([&]() -> const Series<{cpp_t}>& {{ " + f"{cpp_t} _sv = {bridge_cpp}; " + f"if (history_advances_new_bar()) {member}.push(_sv); " + f"else {member}.update(_sv); " + f"return {member}; }}())" + ) + # A chart-executed plain UDF's history-reading parameter + # belongs to this lexical call site, not to the caller Series + # object. The on_bar preamble has already advanced its + # synthetic buffer by one chart slot, so execution replaces + # that slot rather than pushing a second one. return ( f"([&]() -> const Series<{cpp_t}>& {{ " f"{cpp_t} _sv = {bridge_cpp}; " - f"if (history_advances_new_bar()) {member}.push(_sv); " - f"else {member}.update(_sv); " + f"{member}.update(_sv); " f"return {member}; }}())" ) # A concrete nullable collection specialization learned through an diff --git a/tests/test_calc_on_order_fills_codegen.py b/tests/test_calc_on_order_fills_codegen.py index a9c2188..eae01f2 100644 --- a/tests/test_calc_on_order_fills_codegen.py +++ b/tests/test_calc_on_order_fills_codegen.py @@ -220,9 +220,11 @@ def test_post_fill_recalc_updates_current_history_slot_but_barstate_stays_new(): on_bar, ) assert re.search( - r"if \(history_advances_new_bar\(\)\) _series_arg_\d+\.push\(_sv\);", + r"if \(history_advances_new_bar\(\)\) " + r"(_udf_series_arg_\d+)\.push\(\1\.current\(\)\);", on_bar, ) + assert re.search(r"_udf_series_arg_\d+\.update\(_sv\);", on_bar) # Mutation guard: coupling barstate.isnew to history advancement would make # it false during historical fill recalcs, contrary to Pine semantics. @@ -275,7 +277,9 @@ def test_inline_history_buffers_are_owned_independent_and_clear_at_bar_zero(): cpp = transpile(_INLINE_BUFFER_PROBE) fields = _checkpoint_fields(cpp) hist_members = re.findall(r"^\s+Series (_hist_call_\d+);$", cpp, re.MULTILINE) - arg_members = re.findall(r"^\s+Series (_series_arg_\d+);$", cpp, re.MULTILINE) + arg_members = re.findall( + r"^\s+Series (_udf_series_arg_\d+);$", cpp, re.MULTILINE + ) # Two top-level sites plus one site in each wrapped() call-site clone. assert len(hist_members) == 4 @@ -290,21 +294,28 @@ def test_inline_history_buffers_are_owned_independent_and_clear_at_bar_zero(): for member in hist_members + arg_members: index = fields[member] assert f"if (history_advances_new_bar() && bar_index_ == 0) {member}.clear();" in on_bar - assert f"if (history_advances_new_bar()) {member}.push(" in cpp - assert f"else {member}.update(" in cpp assert re.search(rf"^\s+{re.escape(member)},$", cpp, re.MULTILINE) assert ( f"this->{member} = _pf_script_state_checkpoint_->_pf_value_{index};" in cpp ) + for member in hist_members: + assert f"if (history_advances_new_bar()) {member}.push(" in cpp + assert f"else {member}.update(" in cpp + for member in arg_members: + assert ( + f"if (history_advances_new_bar()) " + f"{member}.push({member}.current());" in on_bar + ) + assert f"{member}.update(_sv);" in cpp wrapped_cs0 = cpp.split("double wrapped_cs0(", 1)[1].split("\n }", 1)[0] wrapped_cs1 = cpp.split("double wrapped_cs1(", 1)[1].split("\n }", 1)[0] assert set(re.findall(r"_hist_call_\d+", wrapped_cs0)).isdisjoint( re.findall(r"_hist_call_\d+", wrapped_cs1) ) - assert set(re.findall(r"_series_arg_\d+", wrapped_cs0)).isdisjoint( - re.findall(r"_series_arg_\d+", wrapped_cs1) + assert set(re.findall(r"_udf_series_arg_\d+", wrapped_cs0)).isdisjoint( + re.findall(r"_udf_series_arg_\d+", wrapped_cs1) ) @@ -335,8 +346,8 @@ def test_nested_synthetic_only_helpers_dispatch_to_distinct_leaf_instances(): leaf0 = cpp.split("double leaf_cs0(", 1)[1].split("\n }", 1)[0] leaf1 = cpp.split("double leaf_cs1(", 1)[1].split("\n }", 1)[0] - assert set(re.findall(r"_series_arg_\d+", leaf0)).isdisjoint( - re.findall(r"_series_arg_\d+", leaf1) + assert set(re.findall(r"_udf_series_arg_\d+", leaf0)).isdisjoint( + re.findall(r"_udf_series_arg_\d+", leaf1) ) @@ -395,7 +406,9 @@ def test_udt_method_synthetic_history_isolated_per_source_call_site(): assert "second = _udt_Box_measure_cs1(" in cpp hist_members = re.findall(r"^\s+Series (_hist_call_\d+);$", cpp, re.MULTILINE) - arg_members = re.findall(r"^\s+Series (_series_arg_\d+);$", cpp, re.MULTILINE) + arg_members = re.findall( + r"^\s+Series (_udf_series_arg_\d+);$", cpp, re.MULTILINE + ) assert len(hist_members) == 2 assert len(arg_members) == 2 fields = _checkpoint_fields(cpp) @@ -403,8 +416,10 @@ def test_udt_method_synthetic_history_isolated_per_source_call_site(): body0 = cpp.split("double _udt_Box_measure_cs0(", 1)[1].split("\n }", 1)[0] body1 = cpp.split("double _udt_Box_measure_cs1(", 1)[1].split("\n }", 1)[0] - assert set(re.findall(r"_(?:hist_call|series_arg)_\d+", body0)).isdisjoint( - re.findall(r"_(?:hist_call|series_arg)_\d+", body1) + assert set( + re.findall(r"_(?:hist_call|udf_series_arg)_\d+", body0) + ).isdisjoint( + re.findall(r"_(?:hist_call|udf_series_arg)_\d+", body1) ) diff --git a/tests/test_callable_series_param_types.py b/tests/test_callable_series_param_types.py index 884a9e4..8c96096 100644 --- a/tests/test_callable_series_param_types.py +++ b/tests/test_callable_series_param_types.py @@ -92,12 +92,16 @@ def test_callable_history_parameters_keep_their_pine_scalar_family() -> None: assert "bool boolHistory_cs0(const Series& src)" in cpp assert "double floatHistory_cs0(const Series& src)" in cpp - # bar_index uses Series at chart scope, so the int callable boundary - # widens its call-site history. Epoch time already uses Series and - # can bind directly; neither path is allowed to detour through double. + # bar_index widens to int64_t at the call-site boundary. Epoch time already + # has that family, but plain UDF calls still own a distinct chart-aligned + # parameter buffer; neither path is allowed to detour through double. assert "auto _pf_series_raw = (pine_bar_index())" in cpp assert "is_na(_pf_series_raw) ? na()" in cpp - assert "udf_time = intHistory_cs4(time);" in cpp + assert "Series _udf_series_arg_" in cpp + assert ( + "udf_time = intHistory_cs4(([&]() -> const Series&" in cpp + ) + assert "auto _pf_series_raw = (current_bar_.timestamp)" in cpp assert "intHistory_cs0(const Series& src)" not in cpp assert "boolHistory_cs0(const Series& src)" not in cpp @@ -306,8 +310,8 @@ def test_transformed_untyped_wrapper_profiles_own_each_history_bridge() -> None: } ''' cpp = transpile(source) - assert "Series _series_arg_" in cpp - assert "Series _series_arg_" in cpp + assert "Series _udf_series_arg_" in cpp + assert "Series _udf_series_arg_" in cpp assert "int64_t outer_cs0(int64_t outerSrc)" in cpp assert "double outer_cs1(double outerSrc)" in cpp assert _compile_and_run(cpp + driver) == "1 19.75 1\n" diff --git a/tests/test_codegen_validation_fixes.py b/tests/test_codegen_validation_fixes.py index 33132b3..80eabc6 100644 --- a/tests/test_codegen_validation_fixes.py +++ b/tests/test_codegen_validation_fixes.py @@ -448,9 +448,10 @@ def test_input_source_passed_to_history_udf_is_series_arg(): assert "src.push(get_input_source" in cpp assert "src.update(get_input_source" in cpp assert "src = get_input_source" not in cpp - assert "lagged_cs0(src, 10)" in cpp or "lagged(src, 10)" in cpp - assert "lagged_cs0(src[0], 10)" not in cpp - assert "lagged(src[0], 10)" not in cpp + assert "Series _udf_series_arg_" in cpp + assert "lagged_cs0(([&]() -> const Series&" in cpp + assert "auto _pf_series_raw = (src[0])" in cpp + assert "_udf_series_arg_1.update(_sv)" in cpp compile_cpp(cpp, label="input-source-indirect-history-series") @@ -575,11 +576,16 @@ def test_series_parameter_shadowing_global_is_not_remapped_to_clone_member(): wrap_cs1 = cpp.split( "double wrap_cs1(const Series& x)", 1 )[1].split("\n }", 1)[0] - assert "return lag_cs1(x);" in wrap_cs0 + assert "return lag_cs1(([&]() -> const Series&" in wrap_cs0 + assert "auto _pf_series_raw = (x[0])" in wrap_cs0 # The second written path may own a fresh nested history instance. Its # argument must still be the lexical parameter, never the same-spelled # global Series clone. - assert re.search(r"return lag__ni\d+\(x\);", wrap_cs1) + assert re.search( + r"return lag__ni\d+\(\(\[&\]\(\) -> const Series&", + wrap_cs1, + ) + assert "auto _pf_series_raw = (x[0])" in wrap_cs1 assert "x_cs1" not in wrap_cs0 assert "x_cs1" not in wrap_cs1 compile_cpp(cpp, label="series-parameter-shadow-global-clone-remap") diff --git a/tests/test_method_written_callsite_lifecycle.py b/tests/test_method_written_callsite_lifecycle.py index d0ce004..e274dca 100644 --- a/tests/test_method_written_callsite_lifecycle.py +++ b/tests/test_method_written_callsite_lifecycle.py @@ -258,7 +258,9 @@ def test_method_series_requirement_promotes_wrapper_parameter() -> None: assert "double wrapped_cs0(const Series& src, bool active)" in cpp assert "return _udt_Holder_sample_cs0(holder, src, active);" in cpp - assert "first = wrapped_cs0(_s_close, true);" in cpp + assert "Series _udf_series_arg_" in cpp + assert "first = wrapped_cs0(([&]() -> const Series&" in cpp + assert "auto _pf_series_raw = (current_bar_.close)" in cpp assert _compile_and_run( cpp + _driver(["first"], split_ohlc=True) ) == "105.0\n" @@ -336,8 +338,9 @@ def test_history_series_requirement_flows_from_udf_into_calling_method() -> None "double _udt_Holder_sample_cs0(Holder self, " "const Series& src)" in cpp ) - assert "return history_cs0(src);" in cpp - assert "return history_cs1(src);" in cpp + assert "return history_cs0(([&]() -> const Series&" in cpp + assert "return history_cs1(([&]() -> const Series&" in cpp + assert cpp.count("auto _pf_series_raw = (src[0])") >= 2 assert _compile_and_run( cpp + _driver(["first", "second"], split_ohlc=True) ) == "25.0 2.0\n" diff --git a/tests/test_udf_series_parameter_history.py b/tests/test_udf_series_parameter_history.py new file mode 100644 index 0000000..eacd192 --- /dev/null +++ b/tests/test_udf_series_parameter_history.py @@ -0,0 +1,177 @@ +"""Plain-UDF history parameters follow their written call site's chart clock.""" + +from __future__ import annotations + +import re + +from pineforge_codegen import transpile +from tests.test_runtime_var_initialization import _compile_and_run + + +_SOURCE = '''//@version=6 +strategy("UDF series parameter chart history") +at3(float src) => src[3] +max3(float src) => + float maximum = src[1] + for i = 2 to 3 + if src[i] > maximum + maximum := src[i] + maximum +nested(float src) => at3(src) +execute = bar_index == 0 or bar_index == 4 or bar_index == 8 or bar_index == 12 +source = bar_index == 0 ? 120.0 : + bar_index == 4 ? 80.0 : + bar_index == 8 ? 40.0 : + bar_index == 9 ? 30.0 : + bar_index == 10 ? 20.0 : + bar_index == 11 ? 10.0 : 0.0 +sparseAt3 = execute ? at3(source) : na +skip = not execute +sparseMaxIsHold = skip or max3(source) == 40.0 +alwaysMax3 = max3(source) +nestedAt3 = execute ? nested(source) : na +var bool firstCallMissing = false +var bool nestedFirstCallMissing = false +var float observedSparseAt3 = na +var bool observedSparseMaxIsHold = false +var float observedAlwaysMax3 = na +var float observedNestedAt3 = na +if bar_index == 0 + firstCallMissing := na(sparseAt3) + nestedFirstCallMissing := na(nestedAt3) +if bar_index == 12 + observedSparseAt3 := sparseAt3 + observedSparseMaxIsHold := sparseMaxIsHold + observedAlwaysMax3 := alwaysMax3 + observedNestedAt3 := nestedAt3 +''' + + +_DRIVER = r''' +#include +#include +int main() { + std::vector bars; + for (int i = 0; i <= 12; ++i) { + double value = 100.0 + i; + bars.push_back(Bar{value, value, value, value, 1.0, + 1700000000000LL + i * 60000LL}); + } + GeneratedStrategy strategy; + strategy.run(bars.data(), static_cast(bars.size())); + std::cout << strategy.observedSparseAt3 << " " + << strategy.observedSparseMaxIsHold << " " + << strategy.observedAlwaysMax3 << " " + << strategy.observedNestedAt3 << " " + << strategy.firstCallMissing << " " + << strategy.nestedFirstCallMissing << "\n"; +} +''' + + +def test_plain_udf_history_parameters_hold_last_on_skipped_chart_bars() -> None: + """Discriminate alias, compact, hole, and chart-aligned hold-last models.""" + cpp = transpile(_SOURCE) + + # Direct, lazy-or, always-called, nested forwarding, and its outer call all + # own independent buffers. They advance in the on_bar preamble and an + # execution only replaces the already-created current slot. + members = re.findall( + r"^\s+Series (_udf_series_arg_\d+);$", cpp, re.MULTILINE + ) + assert len(members) == 5 + assert len(set(members)) == 5 + for member in members: + assert ( + f"if (history_advances_new_bar()) " + f"{member}.push({member}.current());" in cpp + ) + assert f"{member}.update(_sv);" in cpp + assert f"if (history_advances_new_bar()) {member}.push(_sv);" not in cpp + + # LLLL discriminator semantics: both sparse sites hold 40, the independent + # always-called site sees caller-chart history max(10, 20, 30), nested + # forwarding has its own matching clock, and first-call prehistory is na. + assert _compile_and_run(cpp + _DRIVER) == "40 1 30 40 1 1\n" + + +def test_plain_udf_history_buffers_are_rollback_owned() -> None: + cpp = transpile(_SOURCE) + members = re.findall( + r"^\s+Series (_udf_series_arg_\d+);$", cpp, re.MULTILINE + ) + for member in members: + assert re.search(rf"^\s+{re.escape(member)},$", cpp, re.MULTILINE) + assert re.search( + rf"this->{re.escape(member)} = " + rf"_pf_script_state_checkpoint_->_pf_value_\d+;", + cpp, + ) + + +def test_requested_context_udfs_keep_their_separate_legacy_clock() -> None: + """Chart callsites get the factor; requested-context clones do not.""" + source = '''//@version=6 +strategy("requested UDF history scope") +history(float src) => src[1] +outer(float src) => history(src) +chart = outer(close) +requested = request.security(syminfo.tickerid, "60", outer(close)) +requestedLower = request.security_lower_tf( + syminfo.tickerid, "1", outer(close) +) +''' + cpp = transpile(source) + members = re.findall( + r"^\s+Series (_udf_series_arg_\d+);$", cpp, re.MULTILINE + ) + + # Only the chart's outer() call and its nested history() call own this + # chart-clocked state. The request.security and security_lower_tf variants + # are inlined by security.py and retain their established direct aliases. + assert len(members) == 2 + assert "double outer_cs0(const Series& src)" in cpp + assert "_udf_series_arg_1.update(_sv)" in cpp + assert "chart = outer_cs0(([&]() -> const Series&" in cpp + for index in (1, 2): + body = cpp.split( + f"double outer_cs{index}(const Series& src)", 1 + )[1].split("\n }", 1)[0] + assert "_udf_series_arg_" not in body + assert re.search(r"return history(?:__ni\d+|_cs\d+)\(src\);", body) + + security_evaluators = cpp.split("void _eval_security_0", 1)[1].split( + "void evaluate_security", 1 + )[0] + assert "_udf_series_arg_" not in security_evaluators + + +def test_requested_context_nested_in_chart_wrapper_stays_out_of_chart_clock() -> None: + source = '''//@version=6 +strategy("wrapped requested UDF history scope") +history(float src) => src[1] +outer(float src) => history(src) +securityWrapper() => request.security( + syminfo.tickerid, "60", outer(close) +) +lowerWrapper() => request.security_lower_tf( + syminfo.tickerid, "1", outer(close) +) +requested = securityWrapper() +requestedLower = lowerWrapper() +''' + cpp = transpile(source) + + # The wrappers execute on the chart, but their expression arguments do + # not: security.py inlines outer()/history() on each requested clock. + assert "_udf_series_arg_" not in cpp + outer_bodies = re.findall( + r"double outer(?:_cs\d+|__ni\d+)?\(const Series& src\) " + r"\{(.*?)\n \}", + cpp, + re.DOTALL, + ) + assert outer_bodies + for body in outer_bodies: + assert "_udf_series_arg_" not in body + assert re.search(r"return history(?:__ni\d+|_cs\d+)\(src\);", body)