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
7 changes: 7 additions & 0 deletions pineforge_codegen/codegen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ def __init__(self, ctx: AnalyzerContext) -> None:
self._active_var_remap: dict[str, str] = {}
# Set of var/series member names that belong to user functions (need cloning)
self._func_var_members_set: set[str] = set()
# BUG C: function-local names emitted as ``UDT*`` pointer aliases (a UDT
# local initialised from a var/global UDT lvalue, mutated through, AND
# later rebound to a different lvalue). Member access lowers to ``->``
# and rebinds to ``&(...)``. Reset per function in _emit_func_def is not
# needed: names are function-unique and the value-copy fallback ignores
# entries for inactive functions.
self._udt_ptr_alias_locals: set[str] = set()
self._precalc_loop_active: bool = False
# Names of ``var`` members that live in a FUNCTION scope (not global).
# These are initialized once-per-function-variant on first call (a
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 @@ -849,6 +849,18 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No
self._active_call_site_idx = None

prev_func_locals = self._current_func_locals
prev_func_body = getattr(self, "_current_func_body", None)
prev_func_name = getattr(self, "_active_func_name", None)
# The function body is the lexical scope used by the UDT-alias analysis
# (BUG C): a local initialised from a var/global UDT lvalue and later
# mutated through must alias, not value-copy.
self._current_func_body = node.body
self._active_func_name = fi.name
# Pointer-aliased UDT locals are function-scoped: a name like ``p_ivot``
# may be a rebinding pointer alias in one function and a ``pivot&``
# parameter in another, so reset per function to avoid cross-contamination.
prev_ptr_alias = self._udt_ptr_alias_locals
self._udt_ptr_alias_locals = set()
self._current_func_locals = {n for n, _, _ in self.ctx.func_var_members.get(fi.name, [])}
# Plain (non-persistent) scalar locals are emitted inline and live in
# no other set; collect them so the unknown-identifier guard in
Expand Down Expand Up @@ -917,6 +929,9 @@ def _emit_func_def(self, fi: FuncInfo, lines: list[str], call_site_idx: int | No
self._current_func_series_params = set()
self._udt_param_udt = {}
self._current_func_locals = prev_func_locals
self._current_func_body = prev_func_body
self._active_func_name = prev_func_name
self._udt_ptr_alias_locals = prev_ptr_alias
self._active_ta_remap = {}
self._active_var_remap = {}
self._in_ta_func_variant = False
Expand Down
32 changes: 24 additions & 8 deletions pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,28 @@ def _collect_security_ta_indices(self, expr_node, resolving: set[str] | None = N
)
return out

def _emit_security_ohlc_hist_pushes(self, sec_id: int, lines: list[str]) -> None:
"""Emit the OHLC history-offset Series pushes for ``sec_id``, gated on
``is_complete``.

``request.security(..., [high[1], low[1], ...], ...)`` reads HTF OHLC at
past-bar offsets. Each offset is backed by a per-field Series whose
history must advance once per COMPLETED HTF bar — not once per (partial)
chart-bar evaluation. ``_eval_security_N`` fires on every chart bar; only
the bar that completes the HTF aggregate has ``is_complete == true``.
Pushing unconditionally advanced the offset history every chart bar, so
``high[1]`` resolved to a recent partial bar instead of the prior
completed HTF bar. Gate all pushes for this sec in one combined block."""
fields = sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ()))
if not fields:
return
lines.append(" if (is_complete) {")
for field in fields:
lines.append(
f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});"
)
lines.append(" }")

def _emit_security_evaluators(self, lines: list[str]) -> None:
"""Emit _eval_security_N() methods and evaluate_security() dispatch."""
if not self._security_calls:
Expand Down Expand Up @@ -1341,10 +1363,7 @@ def emit_security_ta(indices: list[int]) -> None:
emitted_lines=lines,
)
lines.append(f" _req_sec_{sec_id}_{i} = {el_cpp};")
for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
lines.append(
f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});"
)
self._emit_security_ohlc_hist_pushes(sec_id, lines)
lines.append(" }")
lines.append("")
continue
Expand Down Expand Up @@ -1372,10 +1391,7 @@ def emit_security_ta(indices: list[int]) -> None:
)
else:
lines.append(f" _req_sec_{sec_id} = {expr_cpp};")
for field in sorted(self._security_ohlc_hist_fields_by_sec.get(sec_id, ())):
lines.append(
f" {self._security_ohlc_hist_series_cpp(sec_id, field)}.push(bar.{field});"
)
self._emit_security_ohlc_hist_pushes(sec_id, lines)
lines.append(" }")
lines.append("")

Expand Down
7 changes: 6 additions & 1 deletion pineforge_codegen/codegen/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,12 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
# must be promoted to ``int64_t`` so the ``na`` sentinel (``INT64_MIN``)
# survives — narrowing to 32-bit ``int`` collapses it to ``0`` and breaks
# ``is_na<int>`` detection.
INT64_BUILTINS = {"time", "time_close", "timestamp", "time_tradingday"}
INT64_BUILTINS = {"time", "time_close", "timenow", "timestamp", "time_tradingday"}

# Subset of INT64_BUILTINS spelled as a bare identifier (no call args) in Pine.
# ``entryTime := time`` parses the RHS as an ``Identifier`` named ``time`` (not a
# ``FuncCall``), so int64 promotion must match these by identifier name too.
INT64_BUILTIN_IDENTIFIERS = {"time", "time_close", "timenow"}

PINE_TYPE_TO_CPP = {
"int": "int", "float": "double", "bool": "bool", "string": "std::string",
Expand Down
191 changes: 178 additions & 13 deletions pineforge_codegen/codegen/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,25 +430,190 @@ def _series_type_for(self, name: str) -> str:
return PINE_TYPE_TO_CPP.get(sym.pine_type, "double")
return "double"

def _expr_is_int64_builtin(self, expr) -> bool:
"""True if ``expr`` is a top-level int64-returning Pine builtin: either a
call to one of ``INT64_BUILTINS`` (``time(...)``, ``timestamp(...)``, …)
or a bare ``Identifier`` spelled like ``time`` / ``time_close`` /
``timenow`` (which Pine exposes as a value, not a call)."""
from .tables import INT64_BUILTINS, INT64_BUILTIN_IDENTIFIERS
if expr is None:
return False
if isinstance(expr, FuncCall):
func_name, namespace = self._resolve_callee(expr.callee)
return namespace is None and func_name in INT64_BUILTINS
if isinstance(expr, Identifier):
return expr.name in INT64_BUILTIN_IDENTIFIERS
return False

def _int64_reassign_targets(self) -> set[str]:
"""Names of vars that are reassigned (``:=``/``=``) anywhere in the AST
with an RHS that is a top-level int64-returning builtin. Cached on the
instance. Pine ``int`` collapses these to 32-bit, but the runtime stores
the epoch in 64 bits, so the member must be promoted to ``int64_t``."""
cached = getattr(self, "_int64_reassign_cache", None)
if cached is not None:
return cached
from ..ast_nodes import Assignment
targets: set[str] = set()
ast = getattr(self.ctx, "ast", None)
if ast is not None:
for node in self._walk_ast(ast):
if (isinstance(node, Assignment)
and isinstance(node.target, Identifier)
and self._expr_is_int64_builtin(node.value)):
targets.add(node.target.name)
self._int64_reassign_cache = targets
return targets

def _is_int64_builtin_init(self, name: str) -> bool:
"""True if ``name``'s defining expression is a top-level call to a
Pine builtin that returns ``int64_t`` (``time``, ``time_close``,
``timestamp``). The Pine type system collapses these to ``int``
but the engine encodes the ``na`` sentinel in the upper 32 bits,
so storing into ``Series<int>`` would silently corrupt na detection.
"""True if ``name``'s initializer OR any ``:=``/``=`` reassignment has an
RHS that is a top-level int64-returning builtin (``time``, ``time_close``,
``timenow``, ``timestamp``, ``time_tradingday``). The Pine type system
collapses these to ``int`` but the engine encodes the ``na`` sentinel
(and the full epoch-ms value, which overflows int32) in 64 bits, so
storing into ``int`` silently corrupts both the value and na detection.
A reassignment like ``var int entryTime = na`` then ``entryTime := time``
must promote even though the *initializer* alone is ``na``.
"""
from .tables import INT64_BUILTINS
expr = (
self.ctx.global_expr_map.get(name)
or self.ctx.var_member_init_exprs.get(name)
)
if expr is None:
return False
if isinstance(expr, FuncCall):
func_name, namespace = self._resolve_callee(expr.callee)
if namespace is None and func_name in INT64_BUILTINS:
return True
return False
if self._expr_is_int64_builtin(expr):
return True
return name in self._int64_reassign_targets()

# ------------------------------------------------------------------
# BUG C: user-defined-UDT lvalue aliasing
# ------------------------------------------------------------------

def _is_udt_lvalue(self, expr) -> str | None:
"""If ``expr`` is a *user-defined* UDT lvalue (a bare ``Identifier`` that
names a class-scope ``var``/global UDT member, e.g. ``wyckoffSwingLow``),
return its UDT type name; else ``None``.

Pine UDTs are reference types, so a local initialised from such an lvalue
and then mutated through must write back to the global. Drawing UDTs are
handled by the separate ``_uses_drawing`` path and are excluded here."""
if not isinstance(expr, Identifier):
return None
udt_t = self._udt_var_types.get(expr.name)
if udt_t is None or udt_t not in self._udt_defs:
return None
if udt_t in DRAWING_TYPE_TO_CPP:
return None
# Must be a known global/class-scope member (not a function param or a
# plain local snapshot) for write-through to be observable.
if expr.name in getattr(self, "_current_func_locals", set()):
# A function-local of UDT type that is itself a persistent ``var``
# member still write-through aliases; but a plain inline local does
# not represent shared state. Only treat ``var`` func-locals (in
# func_var_members) as aliasable shared state.
fname = getattr(self, "_active_func_name", None)
var_locals = {n for n, _, _ in self.ctx.func_var_members.get(fname, [])} if fname else set()
if expr.name not in var_locals:
return None
return udt_t

def _udt_lvalue_selection_type(self, expr) -> str | None:
"""UDT type if ``expr`` is a UDT lvalue OR a ternary/switch whose every
selectable branch is a UDT lvalue of the SAME user-defined UDT type.
Returns ``None`` otherwise (so plain ``UDT a = b`` value-snapshots, calls,
``.new(...)`` ctors, and mixed/non-lvalue selections never alias)."""
direct = self._is_udt_lvalue(expr)
if direct is not None:
return direct
branches: list = []
if isinstance(expr, Ternary):
branches = [expr.true_val, expr.false_val]
elif isinstance(expr, SwitchStmt):
for _case_expr, stmts in (expr.cases or []):
if not stmts:
return None
last = stmts[-1]
branches.append(last.expr if isinstance(last, ExprStmt) else last)
if expr.default_body:
last = expr.default_body[-1]
branches.append(last.expr if isinstance(last, ExprStmt) else last)
else:
return None
if not branches:
return None
types = {self._is_udt_lvalue(b) for b in branches}
if len(types) == 1 and None not in types:
return next(iter(types))
return None

def _udt_local_alias_kind(self, node: VarDecl) -> tuple[str, str] | None:
"""Decide whether a hintless/typed local UDT declaration must ALIAS the
global(s) it selects rather than value-copy (BUG C).

Returns ``("ref", udt_type)`` for a non-rebinding reference alias,
``("ptr", udt_type)`` for a pointer alias (the local is later reassigned
to a *different* UDT lvalue, which a C++ reference cannot do), or
``None`` to keep the existing value-copy semantics.

Conditions (all required):
* RHS is a UDT lvalue or a ternary/switch selecting same-typed UDT
lvalues (``_udt_lvalue_selection_type``).
* The local is MUTATED later in the enclosing function body
(``local.field := ...``) — a pure read-only snapshot needn't alias.

The mutation requirement is the safety guard: a local that is only read
keeps value semantics, and a local initialised from a non-lvalue (a
``.new()`` ctor, a function return, or a plain local copy) returns
``None`` here, preserving intentional independent-copy semantics."""
from ..ast_nodes import Assignment
body = getattr(self, "_current_func_body", None)
if body is None:
return None
udt_t = self._udt_lvalue_selection_type(node.value)
if udt_t is None:
return None
name = node.name
mutated = False
rebinds_to_other_lvalue = False
for stmt in self._walk_ast_list(body):
if not isinstance(stmt, Assignment):
continue
tgt = stmt.target
# Mutation through the local: ``p.field := ...``
if (isinstance(tgt, MemberAccess)
and isinstance(tgt.object, Identifier)
and tgt.object.name == name):
mutated = True
# Rebind of the local itself to another UDT lvalue: ``p := other``
elif isinstance(tgt, Identifier) and tgt.name == name:
if self._udt_lvalue_selection_type(stmt.value) is not None:
rebinds_to_other_lvalue = True
else:
# Reassigned to a non-lvalue (e.g. ``.new()`` / a copy):
# aliasing would be wrong; bail to value-copy.
return None
if not mutated:
return None
return ("ptr" if rebinds_to_other_lvalue else "ref"), udt_t

def _walk_ast_list(self, stmts):
"""Yield every node within a list of statements (depth-first)."""
for s in stmts:
yield from self._walk_ast(s)

def _addr_of_udt_selection(self, expr, local_name: str):
"""Render the address-of form of a UDT lvalue selection for a pointer
alias (BUG C rebind case): ``other`` -> ``&(other)``;
``cond ? a : b`` -> ``(cond ? &(a) : &(b))``. The selectable branches are
guaranteed (by ``_udt_lvalue_selection_type``) to be UDT lvalues."""
if isinstance(expr, Identifier):
return f"&({self._safe_name(expr.name)})"
if isinstance(expr, Ternary):
cond = self._visit_expr(expr.condition)
t = self._addr_of_udt_selection(expr.true_val, local_name)
f = self._addr_of_udt_selection(expr.false_val, local_name)
return f"({cond} ? {t} : {f})"
# Switch selection: lower to nested ternaries over case equality. Rare in
# practice; fall back to address-of the whole lowered expression.
return f"&({self._visit_expr(expr)})"

def _infer_cpp_type_for_security_elem(self, node) -> str:
"""C++ type for one element of the ``request.security(..., expr, ...)`` payload.
Expand Down
37 changes: 37 additions & 0 deletions pineforge_codegen/codegen/visit_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,17 @@ def _visit_arg_for_series(arg_node, arg_idx):
all_args.extend(self._visit_expr(v) for v in node.kwargs.values())
else:
all_args = [_visit_arg_for_series(a, i) for i, a in enumerate(node.args)]
# Drawing-style/visual CONSTANT passed positionally into a user function's
# ``string`` parameter: ``label.style_*`` / ``size.*`` / other
# DRAWING_STYLE_NS members lower to the bare token ``"0"`` (they only ever
# feed dropped visual kwargs). Bound to a ``std::string`` parameter, that
# ``0`` constructs ``std::string((char const*)0)`` at the call site -> a
# null-pointer ``strlen`` crash at runtime. Coerce such args to
# ``std::string("")`` so the (inert, visual-only) value is a valid empty
# string. Only touches user functions with a known string param and an
# arg that is exactly such a drawing-style constant read.
if namespace is None and func_name in self._func_names:
self._coerce_drawing_style_string_args(func_name, node.args, all_args)
# Default args (parser does not store defaults): isInSession(sess, res = timeframe.period)
if namespace is None and func_name in self._func_names:
fi = self._func_info_map.get(func_name)
Expand Down Expand Up @@ -1089,6 +1100,32 @@ def _visit_arg_for_series(arg_node, arg_idx):
emit_name = f"{self._func_safe_name(func_name)}_cs{self._active_call_site_idx}"
return f"{prefix}{emit_name}({', '.join(all_args)})"

def _coerce_drawing_style_string_args(self, func_name, arg_nodes, all_args) -> None:
"""In-place coerce positional args bound to a ``std::string`` user-function
parameter that lowered to the bare token ``"0"`` from a drawing-style /
visual constant (``label.style_*`` etc.). Such a literal ``0`` binds as
``std::string((char const*)0)`` and segfaults on first use. Replace with
``std::string("")`` (the value is visual-only and inert in a backtest)."""
from .tables import DRAWING_STYLE_NS
fi = self._func_info_map.get(func_name)
if not fi or not getattr(fi, "node", None) or not fi.node.params:
return
specs = getattr(fi, "param_type_specs", []) or []
for i, arg in enumerate(arg_nodes):
if i >= len(all_args) or all_args[i] != "0":
continue
# Only when the destination parameter is a string.
spec = specs[i] if i < len(specs) else None
is_string_param = spec is not None and getattr(spec, "kind", None) == "primitive" \
and getattr(spec, "name", None) == "string"
if not is_string_param:
continue
# Only when the source really is a drawing-style/visual constant read
# (so we never silently turn a numeric ``0`` into an empty string).
if (isinstance(arg, MemberAccess) and isinstance(arg.object, Identifier)
and arg.object.name in DRAWING_STYLE_NS):
all_args[i] = 'std::string("")'

def _visit_fixnan(self, node: FuncCall) -> str:
"""Emit fixnan with persistent state member."""
self._fixnan_counter += 1
Expand Down
Loading
Loading