From aef3c96ea8ae09713ebffab732ce4eaea36a652f Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 12 Aug 2026 12:27:59 +0800 Subject: [PATCH] fix: hold last values for history-reading UDF series parameters at skipped callsites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TradingView keeps a per-requested-context history for user-defined-function series parameters: when a callsite is skipped on a bar (conditional execution), the parameter's series holds its last value rather than recording na, and history references read that held series per callsite. The generated code recorded na on skipped bars, desynchronizing every strategy whose UDF reads parameter history behind a condition. Evidence: browser-oracle discriminators udf-callsite-series-parameter-hold-last (four-model: TV=LLLL vs engine LSSL) and requested-context-udf-callsite-history- hold-last-and-last-invocation (eight-model: TV=LLLL vs LSLL); matrix codegen-dub4art-chart-udf-callsite-history-hold-last-full-e8: the fix cell is an exact closed-trade MATCH (digest e3ba507f) on the full 305-trade dub4art tape vs all-off DIVERGENCE; promotion promote-udf-holdlast-e829: PASS — band 1 entering (dub4art weak->excellent) / 0 leaving, corpus 312/312 zero regressions, tapes at corpus head 95074e0 unchanged. Co-Authored-By: Claude Fable 5 --- pineforge_codegen/codegen/base.py | 142 +++++++++++++- pineforge_codegen/codegen/emit_top.py | 15 ++ pineforge_codegen/codegen/visit_call.py | 48 +++-- tests/test_calc_on_order_fills_codegen.py | 37 ++-- tests/test_callable_series_param_types.py | 16 +- tests/test_codegen_validation_fixes.py | 16 +- .../test_method_written_callsite_lifecycle.py | 9 +- tests/test_udf_series_parameter_history.py | 177 ++++++++++++++++++ 8 files changed, 419 insertions(+), 41 deletions(-) create mode 100644 tests/test_udf_series_parameter_history.py 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)