Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 141 additions & 1 deletion pineforge_codegen/codegen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 (
Expand Down
15 changes: 15 additions & 0 deletions pineforge_codegen/codegen/emit_top.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
48 changes: 33 additions & 15 deletions pineforge_codegen/codegen/visit_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
37 changes: 26 additions & 11 deletions tests/test_calc_on_order_fills_codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<double> (_hist_call_\d+);$", cpp, re.MULTILINE)
arg_members = re.findall(r"^\s+Series<double> (_series_arg_\d+);$", cpp, re.MULTILINE)
arg_members = re.findall(
r"^\s+Series<double> (_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
Expand All @@ -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)
)


Expand Down Expand Up @@ -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)
)


Expand Down Expand Up @@ -395,16 +406,20 @@ 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<double> (_hist_call_\d+);$", cpp, re.MULTILINE)
arg_members = re.findall(r"^\s+Series<double> (_series_arg_\d+);$", cpp, re.MULTILINE)
arg_members = re.findall(
r"^\s+Series<double> (_udf_series_arg_\d+);$", cpp, re.MULTILINE
)
assert len(hist_members) == 2
assert len(arg_members) == 2
fields = _checkpoint_fields(cpp)
assert set(hist_members + arg_members) <= fields.keys()

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)
)


Expand Down
16 changes: 10 additions & 6 deletions tests/test_callable_series_param_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,16 @@ def test_callable_history_parameters_keep_their_pine_scalar_family() -> None:
assert "bool boolHistory_cs0(const Series<bool>& src)" in cpp
assert "double floatHistory_cs0(const Series<double>& src)" in cpp

# bar_index uses Series<int> at chart scope, so the int callable boundary
# widens its call-site history. Epoch time already uses Series<int64_t> 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<int64_t>()" in cpp
assert "udf_time = intHistory_cs4(time);" in cpp
assert "Series<int64_t> _udf_series_arg_" in cpp
assert (
"udf_time = intHistory_cs4(([&]() -> const Series<int64_t>&" in cpp
)
assert "auto _pf_series_raw = (current_bar_.timestamp)" in cpp
assert "intHistory_cs0(const Series<double>& src)" not in cpp
assert "boolHistory_cs0(const Series<double>& src)" not in cpp

Expand Down Expand Up @@ -306,8 +310,8 @@ def test_transformed_untyped_wrapper_profiles_own_each_history_bridge() -> None:
}
'''
cpp = transpile(source)
assert "Series<int64_t> _series_arg_" in cpp
assert "Series<double> _series_arg_" in cpp
assert "Series<int64_t> _udf_series_arg_" in cpp
assert "Series<double> _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"
Expand Down
Loading
Loading