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
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,40 @@ you delete or weaken the special case, the test will tell you.
in Pine v6; both lack `direction`. `TRADE_ACCESSOR_METHODS` is kept
as the union for back-compat but new code should prefer the side-
specific constant.
9. **Top-level lazy-edge `ta.*` sites whose history is read are hoisted to
every-bar evaluation.** TradingView (pinned 2026-09-03 with `lab tv`,
NYSE:F 1D) advances a stateful `ta.*` call on EVERY bar when it sits below
a Pine-v6 lazy `and`/`or` RHS or a ternary arm of a top-level statement
AND the call's own history is referenced (`ta.sma(close, 5)[1]`; the
bare twins of every tape are per-execution);
short-circuiting gates only the value, and `[1]` on it is the previous
BAR. Without a `[k]` read the reached-only inline compute is TV's clock
(oliver1002 / louislapis9 / ycelestine77 / quantbyboji / miemomo3 exact at
100% on it, 2026-09-04). For a `[k]`-read site codegen emits
`const auto _pf_every_bar_ta_N = <site>;` (plus the site's `_hist_call_*`
push for a direct `[k]`) BEFORE the statement, in dynamic mode too
(`codegen/ta.py::_lazy_edge_ta_hoist_plan`, `_emit_lazy_edge_ta_hoists`;
`tests/test_lazy_edge_ta_every_bar.py`). The rule is per family
(cadence-7 ternary/lazy-and probes, same tapes) and the hoist is an
ALLOW-LIST (`LAZY_EVERY_BAR_TA` = highest/lowest/sma/ema) gated on the
`[k]` read: a broad hoist of every family cost 170 tiers / 30 hard lanes
on Cloud Run (2026-09-04), so an unpinned family keeps its existing
lowering until a tape pins it. `change`/`mom`/`roc` (`LAZY_SOURCE_CLOCK_TA`) read the
call's OWN held `source[length]` -- written only when the call executes,
held on skipped bars, na before the first execution -- through the
generated `_PFLazySourceClock` + `_pf_lazy_src_hist_N` members
(`tests/test_lazy_source_clock*.py`; this replaced the #64 roc3-only
clock, whose eager first-execution fallback the tapes refute; its eager
chart `source[length]` read between executions closer than `length` bars
is kept for chart-builtin sources via `_pf_lazy_src_chart_N`);
`cum`/`barssince`/`valuewhen`/`cross*`/`rising`/`falling`/`math.sum`
(`LAZY_PER_EXECUTION_TA`) keep the reached-only inline compute, which is
TradingView's per-execution clock, and never precalc.
Sites inside `if`/loop/function bodies, `else if` conditions, `var`
initializers, `request.security` payloads and tuple-returning sites keep
their existing lowering. The old "lazy SMA/EMA must not precalc" pins
(pf-probe-oliver-dual-vol-sma) encoded the refuted per-call clock and were
re-pinned in `test_codegen_validation_fixes.py`.

## How to add a new Pine v6 function

Expand Down
42 changes: 24 additions & 18 deletions pineforge_codegen/codegen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ def func_var_storage(owner: str, raw_name: str) -> str:
# Set of var/series member names that belong to user functions (need cloning)
self._func_var_members_set: set[str] = set()
self._precalc_loop_active: bool = False
# Top-level lazy-edge TA sites hoisted to every-bar evaluation for the
# statement currently being lowered: FuncCall id -> local name, and
# Subscript id -> ``_hist_call_*`` member (see ``ta.py``).
self._hoisted_ta_values: dict[int, str] = {}
self._hoisted_hist_reads: dict[int, str] = {}
# Names of ``var`` members that live in a callable scope (not global).
# Their exact declaration statements own initialization; they must not
# be initialized by the constructor or the global on_bar preamble.
Expand Down Expand Up @@ -3802,7 +3807,7 @@ def generate(self) -> str:
# request.security helper-call results read at a history offset
# (``myHelper()[k]``). Maps (sec_id, node-id) -> backing Series metadata.
self._security_expr_hist_by_node: dict[tuple[int, int], dict] = {}
self._prepare_lazy_saturated_roc3_sites()
self._prepare_lazy_source_clock_sites()

lines: list[str] = []

Expand Down Expand Up @@ -4035,11 +4040,11 @@ def generate(self) -> str:
)
lines.append("")

# Source-shaped lazy ROC call clocks are generated support types, not
# script state themselves. Their per-callsite instances are declared
# below inside GeneratedStrategy and therefore join the automatic COOF
# checkpoint inventory.
self._emit_lazy_saturated_roc3_helper(lines)
# Hold-last source clocks (change/mom/roc below a top-level lazy edge)
# are generated support types, not script state themselves. Their
# per-callsite instances are declared below inside GeneratedStrategy
# and therefore join the automatic COOF checkpoint inventory.
self._emit_lazy_source_clock_helper(lines)

# 2. Open class
lines.append("class GeneratedStrategy : public BacktestEngine {")
Expand Down Expand Up @@ -4195,20 +4200,21 @@ def generate(self) -> str:
lines.append(f" std::vector<{vtype}> _precalc_{site.member_name};")
lines.append(" bool _use_precalc = false;")

for clock_name in self._lazy_saturated_roc3_clock_by_node.values():
for info in self._lazy_source_clock_by_node.values():
lines.append(
f" {self._lazy_saturated_roc3_type_name} {clock_name};"
)
if self._lazy_saturated_roc3_clock_by_node:
# Dedicated eager close[3] fallback. Its fixed four-slot capacity
# is independent of the user's max_bars_back directive, which may
# legitimately be smaller than the offset this generated route
# requires. It is ordinary copyable script state and therefore
# joins the automatic COOF checkpoint below.
lines.append(
" Series<double> "
f"{self._lazy_saturated_roc3_history_name}{{4}};"
f" {self._lazy_source_clock_type_name} {info['clock']};"
)
# Held-source history on the chart clock. A literal length sizes
# it exactly (independent of the user's max_bars_back directive,
# which may legitimately be smaller than this generated route
# needs); a runtime length uses the Series default capacity. Both
# are ordinary copyable script state and join the automatic COOF
# checkpoint below.
literal = info["length_literal"]
capacity = f"{{{literal + 1}}}" if literal is not None and literal >= 1 else ""
lines.append(f" Series<double> {info['hist']}{capacity};")
if info["chart"] is not None:
lines.append(f" Series<double> {info['chart']}{capacity};")

# Security evaluator TA members (cloned from expression dependencies)
# Skip for user function call expressions — their TA deps are internal to the function
Expand Down
56 changes: 36 additions & 20 deletions pineforge_codegen/codegen/emit_top.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,28 +934,38 @@ def _emit_on_bar(self, lines: list[str]) -> None:

# A GeneratedStrategy handle may execute multiple batch runs or
# streaming lifecycles. BacktestEngine resets broker/base state, but
# generated members survive. Reset source-shaped lazy ROC clocks and
# their forced eager-fallback close history at the first genuine slot
# of each lifecycle. This lives in on_bar rather than a generated run
# wrapper because stream_begin() enters the base run path directly.
# COOF post-close recalculations have history_advances_new_bar()==false
# and therefore preserve the current committed clock/base.
if self._lazy_saturated_roc3_clock_by_node:
# generated members survive. Reset the hold-last source clocks
# (change/mom/roc below a top-level lazy edge) and their held-source
# Series at the first genuine slot of each lifecycle, then freeze this
# bar's base and record it once per chart bar. This lives in on_bar
# rather than a generated run wrapper because stream_begin() enters the
# base run path directly. COOF post-close recalculations have
# history_advances_new_bar()==false and therefore keep the same base.
if self._lazy_source_clock_by_node:
lines.append(
" if (history_advances_new_bar() && bar_index_ == 0) {"
)
for clock_name in self._lazy_saturated_roc3_clock_by_node.values():
lines.append(f" {clock_name}.reset();")
lines.append(
f" {self._lazy_saturated_roc3_history_name}.clear();"
)
for info in self._lazy_source_clock_by_node.values():
lines.append(f" {info['clock']}.reset();")
lines.append(f" {info['hist']}.clear();")
if info["chart"] is not None:
lines.append(f" {info['chart']}.clear();")
lines.append(" }")
self._emit_history_series_write(
lines,
" ",
self._lazy_saturated_roc3_history_name,
"current_bar_.close",
)
for info in self._lazy_source_clock_by_node.values():
lines.append(f" {info['clock']}.begin_bar(bar_index_);")
self._emit_history_series_write(
lines,
" ",
info["hist"],
f"{info['clock']}.bar_base_source",
)
if info["chart"] is not None:
self._emit_history_series_write(
lines,
" ",
info["chart"],
self._visit_expr(info["chart_source"]),
)

# reset_run_state() owns engine/broker state, while these generated
# Series members belong to the strategy object. Clear all of them on
Expand Down Expand Up @@ -1218,9 +1228,15 @@ def _emit_on_bar(self, lines: list[str]) -> None:
# an input (the default-sized construction already matches Pine).
self._emit_ta_runtime_reset(lines, indent=2)

# d. Visit each statement
# d. Visit each statement. A stateful ``ta.*`` site below a lazy
# ``and``/``or`` RHS or ternary arm of a top-level statement is
# evaluated every bar BEFORE the statement (TV rule, see ``ta.py``).
for stmt in self.ctx.ast.body:
self._visit_stmt(stmt, lines, indent=2)
self._emit_lazy_edge_ta_hoists(stmt, lines, indent=2)
try:
self._visit_stmt(stmt, lines, indent=2)
finally:
self._clear_lazy_edge_ta_hoists()

# e. ``// @pf-trace`` pragma block — emitted last so trace values
# reflect every assignment / strategy call made earlier in the
Expand Down
Loading
Loading