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
28 changes: 22 additions & 6 deletions pineforge_codegen/codegen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,8 +902,15 @@ def generate(self) -> str:
else:
default = self._default_for_spec(spec)
lines.append(f" {cpp_type} {f.name} = {default};")
# NA sentinel (always the last data member). A default-constructed
# UDT - ``var T x = na``, an array fill slot, ``T.copy()`` no-arg -
# is na; the ``T.new(...)`` lowering sets this false. This lets
# ``na(udtVar)`` lower to the ``is_na(const T&)`` overload below
# instead of failing because no ``is_na`` accepts a struct.
lines.append(f" bool __pf_na = true;")
lines.append(f" static {type_name} create() {{ return {type_name}{{}}; }}")
lines.append("};")
lines.append(f"inline bool is_na(const {type_name}& _z) {{ return _z.__pf_na; }}")
lines.append("")

# 1c. Enum constants + string tables for str.tostring(enumVar)
Expand Down Expand Up @@ -1048,13 +1055,22 @@ def generate(self) -> str:
self._map_vars.add(name)
lines.append(f" {self._type_spec_to_cpp(self._map_spec_for_name(name))} {safe};")
continue
# Detect UDT vars: init_str like "TypeName.new(...)"
# Detect UDT vars. Two signals: (1) the analyzer recorded an
# explicit UDT type annotation in ``_udt_var_types`` - this is the
# ONLY signal when the initializer is ``na`` (``var SDZone z = na``),
# where the inferred ``ptype`` is NA->double; (2) the init_str is a
# ``TypeName.new(...)`` constructor. Without (1) the member would
# decl as ``double`` and the later ``z = SDZone{...}`` would not
# compile (assigning SDZone to double).
init_s = str(init_str)
udt_type = None
for udt_name in self._udt_defs:
if init_s.startswith(f"{udt_name}.new"):
udt_type = udt_name
break
udt_type = self._udt_var_types.get(name)
if udt_type not in self._udt_defs:
udt_type = None
if udt_type is None:
for udt_name in self._udt_defs:
if init_s.startswith(f"{udt_name}.new"):
udt_type = udt_name
break
if udt_type:
lines.append(f" {udt_type} {safe};")
continue
Expand Down
48 changes: 45 additions & 3 deletions pineforge_codegen/codegen/emit_top.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,27 @@ def _script_has_input_source(self) -> bool:
for node in self._walk_ast(self.ctx.ast):
if not isinstance(node, FuncCall):
continue
func_name, namespace = self._resolve_callee(node.callee)
if namespace == "input" and func_name == "source":
if self._is_source_input(node):
return True
return False

def _typed_na_init(self, cpp_val: str, name: str, ptype) -> str:
"""Re-type a bare ``na<double>()`` initializer to match a non-double
member's C++ type. A ``var int x = na`` resolves its RHS to
``na<double>()`` (a quiet NaN); constructing/pushing that into an int or
bool member is a NaN->int conversion (UB) that yields garbage and defeats
``is_na<T>()`` (which checks the type sentinel, e.g. INT_MIN). Returns the
value unchanged unless it is exactly ``na<double>()`` and the member type
is non-double."""
if cpp_val != "na<double>()":
return cpp_val
cpp_type = PINE_TYPE_TO_CPP.get(ptype, "double")
if cpp_type == "int" and self._is_int64_builtin_init(name):
cpp_type = "int64_t"
if cpp_type == "double":
return cpp_val
return f"na<{cpp_type}>()"

def _emit_constructor(self, lines: list[str]) -> None:
init_parts: list[str] = []
# TA members with ctor args
Expand Down Expand Up @@ -224,8 +240,14 @@ def _emit_constructor(self, lines: list[str]) -> None:
safe = self._safe_name(name)
if name in self._array_vars or name in self._map_vars:
continue
# UDT-typed var members (``var SDZone z = na``) default-construct to
# na via the struct's in-class ``__pf_na = true``; a ctor init like
# ``z(na<double>())`` would not type-match the struct member.
if name in self._udt_var_types and self._udt_var_types[name] in self._udt_defs:
continue
if name not in self.ctx.series_vars:
cpp_val = self._resolve_known(init_expr)
cpp_val = self._typed_na_init(cpp_val, name, ptype)
if self._is_compile_time_value(cpp_val):
init_parts.append(f"{safe}({cpp_val})")
# Strategy params that map to engine members
Expand Down Expand Up @@ -394,6 +416,25 @@ def _emit_on_bar(self, lines: list[str]) -> None:
lines.append(f" if (is_first_tick_) _s_{field_name}.push({push_expr});")
lines.append(f" else _s_{field_name}.update({push_expr});")

# a1. Push history-referenced scalar bar builtins (time[n], bar_index[n],
# hl2[n], …). They land in ``series_vars`` and are declared as Series
# members (base.py section 6) but — unlike user series vars (pushed at
# their assignment) and bar fields (pushed above) — have no push site,
# so ``[n]`` would read an unfed buffer (the na sentinel) on every bar.
# Push each from its scalar lowering. A builtin whose lowering is a
# self-referential call (e.g. ``time_close`` -> ``time_close()``) is
# skipped — the call would resolve to the shadowing Series member.
from .tables import BAR_BUILTINS
for _bname in sorted(self.ctx.series_vars):
if _bname in self._var_names:
continue
_bexpr = BAR_BUILTINS.get(_bname)
if _bexpr is None or f"{_bname}(" in _bexpr:
continue
_bsafe = self._safe_name(_bname)
lines.append(f" if (is_first_tick_) {_bsafe}.push({_bexpr});")
lines.append(f" else {_bsafe}.update({_bexpr});")

# a2. Push strategy series
for svar in sorted(self._strategy_series_vars):
member = svar.replace("_strat_", "")
Expand Down Expand Up @@ -447,6 +488,7 @@ def _emit_on_bar(self, lines: list[str]) -> None:
continue
if name in self.ctx.series_vars:
cpp_val = self._resolve_known(init_expr)
cpp_val = self._typed_na_init(cpp_val, name, ptype)
lines.append(f" {safe}.push({cpp_val});")
# Also init cloned copies for per-call-site function variants
init_emitted: set[str] = set()
Expand Down Expand Up @@ -490,7 +532,7 @@ def _emit_on_bar(self, lines: list[str]) -> None:
func_name_i, namespace_i = self._resolve_callee(stmt.value.callee)
is_static_global_input = (
stmt.name in self._global_member_vars
and func_name_i != "source"
and not self._is_source_input(stmt.value)
and stmt.name not in self._array_vars
and stmt.name not in getattr(self, "_matrix_specs", {})
and stmt.name not in getattr(self, "_map_vars", {})
Expand Down
27 changes: 26 additions & 1 deletion pineforge_codegen/codegen/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@
}


# Bare C++ identifiers that ``strategy.*`` (and a few other) read-only
# accessors lower to as zero-arg free-function calls — e.g.
# ``strategy.grossprofit`` -> ``gross_profit()`` (see codegen/visit_expr.py
# and codegen/emit_top.py). A user variable emitted with one of these names
# becomes a class member that shadows the engine accessor, so the codegen
# would emit ``gross_profit = gross_profit();`` and clang rejects the call
# ("called object type 'double' is not a function"). Escaping such user
# identifiers in ``_safe_name`` keeps the two namespaces disjoint; the
# accessor call strings are emitted verbatim and never routed through
# ``_safe_name``, so they are unaffected. Keep in sync with the accessor
# lowerings if new bare-call accessors are added.
BUILTIN_ACCESSOR_NAMES = {
"signed_position_size", "position_entry_name",
"count_wintrades", "count_losstrades", "eventrades",
"net_profit", "gross_profit", "gross_loss",
"grossprofit_percent", "grossloss_percent",
"max_contracts_held_all", "max_contracts_held_long",
"max_contracts_held_short", "max_drawdown_percent", "max_runup_percent",
"avg_trade", "avg_trade_percent", "avg_winning_trade", "avg_losing_trade",
"avg_winning_trade_percent", "avg_losing_trade_percent",
"margin_liquidation_price", "open_profit", "current_equity",
"open_trades_capital_held",
}


class NamingHelper:
"""Identifier escaping, callee resolution, and a generic AST walker.

Expand All @@ -56,7 +81,7 @@ def _cpp_string_escape(s: str) -> str:

def _safe_name(self, name: str) -> str:
"""Rename identifiers that collide with C++ reserved words."""
if name in CPP_RESERVED:
if name in CPP_RESERVED or name in BUILTIN_ACCESSOR_NAMES:
return f"_{name}_"
return name

Expand Down
27 changes: 25 additions & 2 deletions pineforge_codegen/codegen/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,27 @@ def _input_type_to_getter(func_name: str | None, namespace: str | None) -> str:
return "get_input_int"
return "get_input_double"

def _is_source_input(self, node: FuncCall) -> bool:
"""True if an ``input.*`` call yields a *live per-bar source series*.

Covers both ``input.source(<native series>)`` and a bare
``input(<native series>)`` — in Pine v6 ``input(close)`` is the
source-input overload and behaves like ``input.source(close)``,
returning a series that tracks ``close`` every bar (not a constant
frozen at the first bar). The defval is restricted to the native
OHLCV series the engine can resolve at runtime (the same set
``input.source`` is restricted to); a bare ``input(14)`` /
``input(\"x\")`` / ``input(true)`` stays a frozen scalar."""
func_name, namespace = self._resolve_callee(node.callee)
if namespace == "input" and func_name == "source":
return True
if func_name == "input" and namespace is None:
default = self._get_input_default(node)
if (isinstance(default, Identifier)
and default.name in self._NATIVE_SOURCE_SERIES):
return True
return False

def _source_defval_to_base_series(self, default) -> str:
"""Map an input.source defval (close/high/hl2/…) to its engine base
source series member (``_src_close_`` …). Falls back to
Expand All @@ -175,8 +196,10 @@ def _render_input_value(self, node: FuncCall, func_name: str | None,
series and we read its current value; a subscripted source var is
already tracked as a series var by the analyzer so ``src[1]`` lowers
to a Series subscript. Every other input type routes through the
scalar getter table."""
if namespace == "input" and func_name == "source":
scalar getter table. A bare ``input(<native series>)`` is the Pine
source-input overload and is rendered identically to
``input.source``."""
if self._is_source_input(node):
default = self._get_input_default(node)
base = self._source_defval_to_base_series(default)
return f'get_input_source("{title}", {base})[0]'
Expand Down
31 changes: 31 additions & 0 deletions pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,37 @@ def emit_security_ta(indices: list[int]) -> None:

self._emit_security_rebinds(sec_id, info, lines, ta_results, indent=2, emitted_lines=lines)
emit_security_ta(post_rebind_ta_indices)
returns_tuple = item.get("returns_tuple", False)
tuple_size = item.get("tuple_size", 0)
if (
returns_tuple
and tuple_size
and tuple_size > 0
and isinstance(expr_node, TupleLiteral)
):
# A tuple body destructures into per-element scalar members
# ``_req_sec_{sec_id}_{i}`` (declared in ``base.py`` and reset in
# ``clear_security``). Assign each element individually rather
# than building the whole ``TupleLiteral`` (which lowers to an
# ``std::make_tuple(...)`` against the non-existent aggregate
# member ``_req_sec_{sec_id}``).
for i, el in enumerate(expr_node.elements):
el_cpp = self._build_security_expr(
sec_id,
el,
None,
ta_results,
security_mutable_names=security_mutable_names,
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});"
)
lines.append(" }")
lines.append("")
continue
expr_cpp = self._build_security_expr(
sec_id,
expr_node,
Expand Down
5 changes: 4 additions & 1 deletion pineforge_codegen/codegen/ta.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,4 +295,7 @@ def _is_compile_time_value(val: str) -> bool:
return True
except ValueError:
pass
return val in ("true", "false", "0", "0.0", "na<double>()")
return val in (
"true", "false", "0", "0.0",
"na<double>()", "na<int>()", "na<int64_t>()", "na<bool>()",
)
8 changes: 4 additions & 4 deletions pineforge_codegen/codegen/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,14 +382,14 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:

# Methods called as ``array.method(arr, ...)`` or ``arr.method(...)``.
ARRAY_METHODS = {
"get": lambda a, args: f"{a}[{args[0]}]",
"set": lambda a, args: f"{a}[{args[0]}] = {args[1]}",
"get": lambda a, args: f"{a}[({args[0]})]",
"set": lambda a, args: f"{a}[({args[0]})] = {args[1]}",
"push": lambda a, args: f"{a}.push_back({args[0]})",
"unshift": lambda a, args: f"{a}.insert({a}.begin(), {args[0]})",
"insert": lambda a, args: f"{a}.insert({a}.begin() + (int)({args[0]}), {args[1]})",
"pop": lambda a, args: f"[&](){{ auto v={a}.back(); {a}.pop_back(); return v; }}()",
"shift": lambda a, args: f"[&](){{ auto v={a}.front(); {a}.erase({a}.begin()); return v; }}()",
"remove": lambda a, args: f"[&](){{ auto v={a}[{args[0]}]; {a}.erase({a}.begin()+(int)({args[0]})); return v; }}()",
"remove": lambda a, args: f"[&](){{ auto v={a}[({args[0]})]; {a}.erase({a}.begin()+(int)({args[0]})); return v; }}()",
"first": lambda a, args: f"{a}.front()",
"last": lambda a, args: f"{a}.back()",
"size": lambda a, args: f"(double){a}.size()",
Expand Down Expand Up @@ -435,7 +435,7 @@ def tz_time_field_lambda(field_expr: str, ts_arg: str, tz_arg: str) -> str:
"mode": lambda a, args: f"[&](){{ std::unordered_map<double,int> m; for(auto v:{a})m[v]++; double best=0; int bc=0; for(auto&[v,c]:m)if(c>bc||(c==bc&&v<best)){{bc=c;best=v;}} return best; }}()",
"percentile_linear_interpolation": lambda a, args: f"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); double k=({args[0]}/100.0)*c.size()-0.5; int i=std::max(0,(int)k); double f=k-i; if(i+1>=(int)c.size()) return c.back(); return c[i]*(1-f)+c[i+1]*f; }}()",
"percentile_nearest_rank": lambda a, args: f"[&](){{ auto c={a}; std::sort(c.begin(),c.end()); int r=(int)std::ceil(({args[0]}/100.0)*c.size()); return c[std::min(r-1,(int)c.size()-1)]; }}()",
"percentrank": lambda a, args: f"[&](){{ if({a}.size()<=1) return na<double>(); double v={a}[{args[0]}]; if(std::isnan(v)) return na<double>(); int le=0; for(auto x:{a}) if(!std::isnan(x) && x<=v) le++; return (double)(le-1)/({a}.size()-1)*100.0; }}()",
"percentrank": lambda a, args: f"[&](){{ if({a}.size()<=1) return na<double>(); double v={a}[({args[0]})]; if(std::isnan(v)) return na<double>(); int le=0; for(auto x:{a}) if(!std::isnan(x) && x<=v) le++; return (double)(le-1)/({a}.size()-1)*100.0; }}()",
"abs": lambda a, args: f"[&](){{ std::vector<double> r; for(auto v:{a})r.push_back(std::abs(v)); return r; }}()",
"join": lambda a, args: "[&](){{ std::string r; for(size_t i=0;i<{arr}.size();i++){{ if(i>0)r+={sep}; r+=std::to_string({arr}[i]); }} return r; }}()".format(arr=a, sep=args[0] if args else 'std::string(",")'),
"standardize": lambda a, args: f"[&](){{ double m=std::accumulate({a}.begin(),{a}.end(),0.0)/{a}.size(); double s=0; for(auto v:{a})s+=(v-m)*(v-m); s=std::sqrt(s/{a}.size()); std::vector<double> r; for(auto v:{a})r.push_back(s==0?1.0:(v-m)/s); return r; }}()",
Expand Down
16 changes: 14 additions & 2 deletions pineforge_codegen/codegen/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,12 @@ def _type_for_decl(self, node: VarDecl) -> str:

def _series_type_for(self, name: str) -> str:
"""C++ element type for a series variable's history buffer."""
if self._is_int64_builtin_init(name):
from .tables import INT64_BUILTINS
# A bare int64 bar builtin used as a history series (``time[1]``) needs an
# int64_t buffer: epoch-ms overflow int32 and the na sentinel would be
# misdetected. ``_is_int64_builtin_init`` only matches user vars whose
# init RHS is such a builtin, so also match the builtin name directly.
if name in INT64_BUILTINS or self._is_int64_builtin_init(name):
return "int64_t"
sym = self.ctx.symbols.resolve(name)
if sym is not None:
Expand Down Expand Up @@ -473,8 +478,15 @@ def _infer_type(self, node) -> str:
return "std::string"
if func_name == "bool":
return "bool"
if func_name in ("int", "color", "time"):
if func_name == "int":
return "int"
# ``input.time`` returns an epoch-MS timestamp and ``input.color``
# a packed ARGB int — both use the ``get_input_int64`` getter
# (input.py), so their storage must be ``int64_t`` or the value
# truncates under int32 (e.g. a date-window bound flips sign and
# the guard is permanently false).
if func_name in ("color", "time"):
return "int64_t"
return "double"
if namespace == "str":
if func_name == "split":
Expand Down
12 changes: 12 additions & 0 deletions pineforge_codegen/codegen/visit_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,13 @@ def _visit_func_call(self, node: FuncCall) -> str:
if namespace == "color":
return self._visit_color_call(func_name, node)

# Bare color(...) cast (cosmetic). The engine has no color-cast helper
# and colors have no backtest-logic effect, so emit a benign default
# color (0 = na color, matching the color.new / from_gradient
# fallbacks). The support checker warns on this construct.
if namespace is None and func_name == "color" and func_name not in self._func_names:
return "0"

# Skip visual/unsupported namespace calls
if namespace in SKIP_NAMESPACES or namespace in SKIP_VAR_TYPES:
return "0"
Expand Down Expand Up @@ -913,6 +920,11 @@ def _visit_func_call(self, node: FuncCall) -> str:
if f_cpp_type == "int" and "na<double>" in val:
val = val.replace("na<double>()", "0")
field_inits.append(f".{f.name} = {val}")
# Mark the constructed object non-na (the struct's ``__pf_na`` is the
# last declared field, so this designator stays in declaration order).
# A bare default-constructed UDT keeps ``__pf_na = true`` (na); only a
# real ``.new(...)`` flips it false so ``na(obj)`` reports correctly.
field_inits.append(".__pf_na = false")
return f"{namespace}{{{', '.join(field_inits)}}}"

# UDT copy: TypeName.copy(obj)
Expand Down
Loading
Loading