AST Transformation
How PyneCore uses AST transformation to implement Pine Script behavior
AST Transformation
Import Hook System
The system’s entry point is the import hook, which transforms Python files marked with the @pyne magic comment:
# Import hook through importlib meta_path system
sys.meta_path.insert(0, PyneImportHook())The PyneLoader class performs code transformation in multiple steps, applying the AST transformation chain.
Marker Recognition Rules
The @pyne marker is recognized strictly to avoid accidentally transforming ordinary library modules that merely mention the token in prose:
- The module’s first statement must be a string-literal expression (a module docstring).
- After
lstrip(), the docstring’s content must start with@pyne. @pynemust be followed by whitespace or the end of the docstring (so@pynexdoes not match, and a docstring like"""Some description.\n@pyne\n"""does not trigger transformation — the marker must come first, not somewhere inside).
A cheap regex prefilter (@pyne(\s|$)) skips full AST parsing for files that obviously cannot match. Files that pass the prefilter still go through the strict AST check above before any transformer runs.
Reserved Identifier Namespace
Every name the transformers inject into script scope carries a Unicode middle dot (·): the scope-qualified state parameters and slot constants (__state·main__, __slot·main·x__), the generated temporaries (__st·__, __cnt·0__) and the aliased runtime helper imports (__resolve_slot·__). The character is a legal Python identifier character, so the hook reserves it: Pyne code whose identifier contains · in any position is rejected with a SyntaxError naming the identifier, before a single transformer runs. Because Python NFKC-normalizes identifiers while parsing, the check compares the normalized form — equivalent spellings (U+0387 GREEK ANO TELEIA, and U+013F / U+0140 LATIN LETTER L WITH MIDDLE DOT, which decompose into one) are rejected the same way. Strings, comments and docstrings may contain it freely.
Transformation Chain
PyneCore applies several key transformations to Python code to make it behave like Pine Script, in this order:
- Import Lifter - Moves function-level imports to module level
- TYPE_CHECKING Stripper - Removes
if TYPE_CHECKING:blocks (IDE-only hints) - Type Erasure - Replaces
typing.cast(T, x)withxand resolvesif TYPE_CHECKING: - Builtin Shadow Transformer - Routes shadowed-alias accesses the library cannot serve back to the built-in namespace
- Import Normalizer - Standardizes import statements
- Outer Write Transformer - Rejects writes into an object created outside a function
- Security Transformer - Rewrites
request.security()calls into signal/write/read/wait pattern (see request.security() Internals) - PersistentSeries Transformer - Splits the hybrid PersistentSeries type
- Library Series Transformer - Prepares library Series variables
- Module Property Transformer - Handles module properties
- Closure Arguments Transformer - Converts closure variables to function arguments
- Unused Series Detector - Removes unnecessary Series annotations for performance
- Series Transformer - Handles Series variables
- Persistent Transformer - Manages persistent variables
- Call Inline Transformer - Copies the body of trivial stateless builtins into the call site
- Function Isolation Transformer - Ensures separate state for each function call
- Input Transformer - Processes input parameters
- Safe Convert Transformer - Lowers the
float()/int()casts to their Pine forms and truncates a Pine int where a Python-native consumer needs a realint - Safe Division Transformer - Protects against division by zero
This order ensures that dependencies between transformations are properly handled. For example, PersistentSeries transformation must happen before both Persistent and Series transformations, and Function Isolation must run after them because it routes calls based on the state slots they allocated. Call Inlining sits directly in front of Function Isolation, so a site that is no longer a call never gets an anchor slot.
Each transformation step modifies the Python AST to implement Pine Script behavior while maintaining Python syntax and readability.
The Slot Layout
The state-related transformers (Series, Persistent, Function Isolation) share one module layout: a table that assigns a slot index to every piece of per-instance state — persistent variables, series buffers, and the state of isolated call sites. At the end of the chain this table is emitted into the module as a plain dict constant, and every state-carrying function gets:
- a hidden first parameter (
__state__) that receives its state vector — a plain Python list whose slots are addressed with literal int indexes, and - a
__pyne_layout__attribute describing how to build such a vector (initial values, series slots,varipslots, child call sites).
__pyne_slot_layout__ = {'main': {'init': (0,), 'series': (), 'varip': (), 'children': (), 'names': ('p',)}}
def main(__state__):
__state__[0] += 1
main.__pyne_layout__ = __pyne_slot_layout__['main']The runtime side of this scheme (who creates the state vectors and when) is described on the Function Isolation page.
Detailed Transformation Process
Import Lifter
The Import Lifter moves function-level imports to module level.
Original code:
def main():
from pynecore.lib.ta import sma
result = sma(close, 14)Transformed code:
from pynecore.lib.ta import sma
def main():
result = sma(close, 14)Key aspects:
- Lifts all pynecore.lib related imports to module level
- Ensures imports are accessible throughout the module
- Prevents duplicate imports
TYPE_CHECKING Stripper
Removes if TYPE_CHECKING: blocks and the TYPE_CHECKING import itself; a statement’s else body is what runs, so it is kept in the statement’s place. These blocks carry IDE-only type hints (casts, re-annotations) that have no runtime role, so stripping them keeps the transformed module free of dead code.
Type Erasure
typing.cast(T, x) exists for the static type checker only: at runtime it returns x unchanged, but it is still a Python-level function call. Inside a builtin that a script calls millions of times, that call costs more than the arithmetic around it. The pass rewrites the call to its value operand:
b = cast(float, base) # before
b = base # afterAn if TYPE_CHECKING: statement is resolved the same way. The name is False at runtime, so the statement is replaced by its else body, or dropped when it has none:
if TYPE_CHECKING: # before
import orjson as json
else:
import json
import json # afterA rewrite happens only when it provably changes nothing:
- the name (
cast,TYPE_CHECKING) is bound by a module-levelfrom typing import ... [as name]orimport typing [as name], and that name is bound nowhere else in the module (no assignment, parameter, definition, other import,except ... as, pattern capture,globalordel). A module that rebinds the name keeps every call through it, soctypes.castor a localcasthelper is never touched; - the call has exactly two positional operands, no keyword and no star operand;
- the type operand is a plain type expression (names, attributes, subscripts, constants, tuples, lists and
|unions of those), so dropping its evaluation loses no effect; - an
if/elifis resolved only when its whole test is the trusted name; a compound test (TYPE_CHECKING or x) is left as written.
The import statement stays in place.
This is the one pass that also runs over PyneCore’s own plain modules (pynecore.lib.math, pynecore.lib.array, pynecore.core.series, …), which is where the casts on the hot path live. Such a module gets this pass and nothing else. Its bytecode carries a __pyne_type_erased__ certificate paired with a digest of the pass, so a cached .pyc compiled without the import hook (pip’s post-install compileall, an IDE) is detected and recompiled, the same way a transformed @pyne module is. A package module that contains neither cast( nor TYPE_CHECKING is never parsed at all. Modules outside the pynecore package are left untouched unless they are @pyne code.
Builtin Shadow Transformer
The Builtin Shadow transformer resolves workdir-library imports whose alias shadows a built-in namespace.
On TradingView, an import TradingView/ta/7 as ta does not hide the built-in ta.* namespace: Pine resolves ta.x against the library’s exports first and falls back to the built-in namespace for everything the library does not export. So ta.valuewhen(...) keeps working even though the TradingView/ta library has no valuewhen export (TV’s own library deliberately names its functions ema2/atr2/rma2 to avoid colliding with the built-ins). A workdir library is imported as import lib.tv.ta.v7 as ta, which would route every access to the library module — and break the shadowed built-in accesses.
This transformer rewrites only the accesses the library cannot serve to the canonical built-in form, so the downstream transformers (import normalizer, module properties, isolation, series) handle them like any other built-in reference.
Original code (workdir-library import):
import lib.tv.ta.v7 as ta
a = ta.valuewhen(cond, src, 0) # not exported by the library
b = ta.t3(src, length) # exported by the libraryTransformed code:
import lib.tv.ta.v7 as ta
a = lib.ta.valuewhen(cond, src, 0) # falls back to the built-in ta namespace
b = ta.t3(src, length) # served by the library, left untouchedKey aspects:
- Only aliases that shadow a known built-in namespace are considered (the namespace must exist in
module_properties.json, withstrmapping to thestringmodule); a non-shadowing alias likeas mytais left untouched - Library membership is runtime knowledge: the library module is imported at transform time — the same pattern function-isolation callee resolution already uses. Its
__all__is the membership test (matching TV, where non-exported names are unreachable through the alias), with ahasattrfallback for hand-written libraries that have no__all__ - Members the library serves stay on the library; members it cannot serve but the built-in namespace can are rewritten to
lib.<namespace>.<member>(the nested-key check also covers sub-namespaces likestrategy.commission) - Runs before the Import Normalizer so the rewritten
lib.<namespace>.<member>chains get theirfrom pynecore import libandimport pynecore.lib.<namespace>statements added there - A function parameter named like the alias masks the fallback inside that scope only
- If the library cannot be imported, the alias is left untouched and the script fails at its own import statement, exactly as before; a member in neither the library nor the built-in namespace is also left on the library to fail at runtime, as before
This keeps a script independent of the libraries it imports: whoever writes or generates it needs no knowledge of a library’s contents, and all library-dependent resolution happens here at transform time, where the library is importable.
Import Normalizer
The Import Normalizer transforms all PyneCore imports to use a consistent format.
Original code:
from pynecore.lib.ta import sma, ema
from pynecore.lib import plot, close
def main():
plot(close)
plot(sma(close, 14))
plot(ema(close, 14))Transformed code:
from pynecore import lib
import pynecore.lib.ta
def main():
lib.plot(lib.close)
lib.plot(lib.ta.sma(lib.close, 14))
lib.plot(lib.ta.ema(lib.close, 14))Key aspects:
- Converts all lib-related imports to ‘from pynecore import lib’
- Transforms variable references to use fully qualified names (lib.ta.sma)
- Maintains compatibility with wildcard imports
- Ensures consistent import style across the codebase
This is very important to make lib level properties work like close, open, high, low, volume, etc.
If you would use this kind of import:
a = closeThat would not work, because the value would never be updated in the next bar. However, after using the import normalizer, it will work:
a = lib.closeBecause the module level variable changed, and we access through the lib module object.
Outer Write Transformer
An object a script creates at module level lives outside everything the runtime rolls back. A
request.security child re-runs main() on a developing higher-timeframe bar and discards the
result, calc_on_order_fills re-executes a bar once per fill, and a live intrabar tick re-executes
the bar per tick. All three restore the script’s own slots (Persistent, Series) and none of them
can restore a plain Python object the script keeps for itself.
This pass rejects the direct forms with a SyntaxError when the script is loaded:
STORE = array.new_float(0)
def main():
array.push(STORE, close) # SyntaxError: 'STORE' is modified inside a functionRejected inside any function: any global statement, an assignment, augmented assignment or del
whose target chain is rooted at a module-level binding, a mutating method call
on one (append, extend, pop, clear, sort, update, …), and a mutating array.* /
matrix.* / map.* builtin whose first argument is one. A local variable or a parameter of the same
spelling shadows the module-level name, so it is not the outer object.
Defining is free: only writing is rejected. A module-level binding whose value is a call
(color.new(...)), an enum member (strategy.fixed) or an exported-function proxy is unaffected,
and reading any of them from a function is unaffected as well.
Functions whose name starts with __test_ are exempt — they are test-harness code rather than script
code.
PersistentSeries Transformer
The PersistentSeries transformer converts the combined PersistentSeries type into separate Persistent and Series declarations.
Original code:
ps: PersistentSeries[float] = 1
ps += 1Transformed code:
p: Persistent[float] = 1
s: Series[float] = p
s += 1Key aspects:
- Splits PersistentSeries declarations into two separate declarations
- Must be applied before both Persistent and Series transformers
This makes easier to declare variables are both persistent and series.
Library Series Transformer
The Library Series transformer prepares library Series variables (like close, open, high, etc.) for proper handling by the Series transformer: every scope that indexes a library value gets a local Series anchor for it.
Original code:
def main():
a = lib.close[1]
def nested():
return lib.high[1]
result = nested()
print(a, result)Transformed code (after the Series transformer has assigned the slots):
__pyne_slot_layout__ = {'main': {'init': (None, None), 'series': ((0, None), (1, None)), 'varip': (), 'children': (), 'names': ('__lib·close', '__lib·high')}}
def main(__state·main__):
__lib·close = __state·main__[0].add(lib.close)
__lib·high = __state·main__[1].add(lib.high)
a = __state·main__[0][1]
def nested():
return __state·main__[1][1]
result = nested()
print(a, result)
main.__pyne_layout__ = __pyne_slot_layout__['main']Key aspects:
- Creates local Series variables for library Series in each scope that needs them
- Uses Unicode middle dot (·) as separator to prevent name collisions
- The buffers anchor in the outermost function that uses them; nested functions reach them through the parent’s state vector
- Prepares variables for Series transformer processing
Collision Prevention: The transformer uses __lib· prefix with Unicode middle dot separators to prevent naming conflicts. For example:
mylib.bar.foobecomes__lib·mylib·bar·foomylib.bar_foobecomes__lib·mylib·bar_foo
This ensures that hierarchical module names cannot collide with underscore-separated names.
If you import a variable from a library, it does not know if it is a series or not. But if you use indexing (subscription) on it, it should initialize it as a series. This is needed, because the AST transformer does not know anything about the other files just the one it is currently transforming.
Module Property Transformer
The Module Property transformer handles attributes that should be called as functions based on
the generated module_properties.json registry.
Original code:
t = lib.time
bar_index = lib.bar_index
plot(close, "Close")
d = dayofweekTransformed code:
t = lib.time()
bar_index = lib.bar_index
lib.plot.plot(lib.close, "Close")
d = lib.dayofweek.dayofweek()Key aspects:
- The registry (
module_properties.json, generated from the lib source byscripts/module_property_collector.py) determines which attributes are properties - Automatically adds parentheses for property calls; explicit calls are left untouched
- Normal attributes (variables, constants, function references) stay plain attribute reads
- Calls and promoted bare reads of function-and-namespace modules (
plot,hline,alert,dayofweek,strategy.opentrades,strategy.closedtrades) are routed to the module’s self-named function - Unknown names on known
pynecore.libmodules raise at transform time — this catches typos and a stale registry early (the test suite keeps the committed registry current) - Unknown module paths (user
lib.*workdir libraries) and_-prefixed names are plain reads
Closure Arguments Transformer
The Closure Arguments transformer converts closure variables in inner functions to explicit function arguments, enabling proper function isolation.
Original code:
@lib.script.indicator("Test")
def main():
length = 14
multiplier = 2.0
def calculate(offset=0):
return lib.ta.sma(lib.close, length) * multiplier + offset
return calculate() + calculate(10)Transformed code:
@lib.script.indicator("Test")
def main():
length = 14
multiplier = 2.0
def calculate(length: int, multiplier: float, offset=0):
return lib.ta.sma(lib.close, length) * multiplier + offset
return calculate(length, multiplier) + calculate(length, multiplier, 10)Key aspects:
- Adds closure variables as function parameters at the beginning of parameter list
- Preserves type annotations from original variable declarations
- Updates all function calls to pass closure variables as arguments
- Only processes functions inside @lib.script.indicator or @lib.script.strategy decorated main functions
- Maintains proper scope isolation for nested functions
- Prepares functions for the Function Isolation transformer
Unused Series Detector
The Unused Series Detector optimizes performance by removing Series annotations from variables that are never indexed with the subscript operator.
Original code:
def main():
# This variable is never indexed - can be optimized
s: Series[float] = close
def f(source: Series[float], m = 1.0):
# This parameter IS indexed - must keep Series annotation
return source * m + s[1]
r = f(s, 2.0)
plot(s)Transformed code:
def main():
# Series annotation removed since s is never indexed in main scope
s: float = close
def f(source: float, m = 1.0):
# Series annotation removed since source is never indexed in f scope
# Note: s[1] refers to the closure variable, not the parameter
return source * m + s[1]
r = f(s, 2.0)
plot(s)Key aspects:
- Uses scope-aware analysis to track variable usage independently in each function scope
- Distinguishes between variables with the same name in different scopes (e.g., closure vs parameter)
- Only removes Series annotations from variables that are never used with subscript syntax
[index] - Runs before SeriesTransformer to prevent unnecessary SeriesImpl creation
- Significantly improves performance by avoiding Series overhead for simple variables
- Preserves type annotations for variables that are actually indexed
Performance Impact: This optimization can dramatically reduce memory usage and improve execution speed by eliminating unnecessary Series object creation for variables that are only used for simple arithmetic operations.
Series Transformer
The Series transformer converts Series annotated variables into operations on a SeriesImpl instance (a circular buffer) living in a slot of the function’s state vector.
Original code:
from pynecore import Series
from pynecore.lib import close
def main():
s: Series[float] = close
s += 1
previous = s[1]
print(previous)Transformed code:
from pynecore import lib
__pyne_slot_layout__ = {'main': {'init': (None,), 'series': ((0, None),), 'varip': (), 'children': (), 'names': ('s',)}}
def main(__state__):
s = __state__[0].add(lib.close)
s = __state__[0].set(s + 1)
previous = __state__[0][1]
print(previous)
main.__pyne_layout__ = __pyne_slot_layout__['main']Key aspects:
- Allocates a series slot in the function’s state vector for each Series variable; the runtime puts a fresh
SeriesImplinto these slots when an instance is created - Converts the declaration to an
add()(push the bar’s value) and assignments toset()operations - Redirects indexing operations to the slot (
s[1]becomes__state__[0][1]) - Statement-position
lib.max_bars_back(s, n)calls become assignments to the slot’smax_bars_backattribute - Each function instance gets its own buffers, because each instance has its own state vector
Persistent Transformer
The Persistent transformer converts variables with Persistent type annotation to slots of the function’s state vector, so they maintain their values across function calls.
Original code:
p: Persistent[float] = 0
p += 1Transformed code:
__pyne_slot_layout__ = {'main': {'init': (0,), 'series': (), 'varip': (), 'children': (), 'names': ('p',)}}
def main(__state__):
__state__[0] += 1
main.__pyne_layout__ = __pyne_slot_layout__['main']Key aspects:
- Allocates a slot with the initial value in the layout’s
inittuple; literal initializers are baked in, non-literal initializers get a lazy init-flag companion slot that triggers the assignment on the instance’s first call - Rewrites every read and write of the variable to the slot (
__state__[0]) IBPersistent(varip) variables get their slot listed in the layout’svariptuple, which excludes them from thevarrollback on intra-bar re-execution- Slot reads/writes are plain list indexing with literal indexes — the fastest state access Python offers
Accumulation: The += operator stays a plain augmented assignment on the slot, so a running sum accumulates naively. That is deliberate: TradingView accumulates the same way (measured on ta.cum and every volume accumulator), and error compensation — a Kahan sum, for instance — would produce a mathematically better sum that no longer matches the reference.
Important Note: The state-related transformers use the Unicode character · (middle dot, U+00B7) as the internal scope separator in slot names and call-site identifiers (e.g. main·t·0). This prevents conflicts when function names contain underscores. Avoid using the · character in function or variable names to prevent conflicts with the internal scoping system.
Call Inline Transformer
A wrapper like math.abs is a few nanoseconds of work behind a Python call that costs tens of them, and a rolling-window script reaches such wrappers tens of millions of times over a run. This pass replaces an allow-listed call with the wrapper’s own body, written out as a single expression.
Original code:
from pynecore.lib import math, close
def main():
return math.abs(close - 1.0)Transformed code:
from pynecore.core.inline_support import na_float as __inl·na_float__, py_builtins as __inl·py_builtins__
def main():
return __inl·na_float__ if not (__inl1·__ := lib.close - 1.0) == __inl1·__ else __inl·py_builtins__.abs(__inl1·__)Key aspects:
- The expression is derived from the wrapper’s own source AST, never hand-written, and only from a restricted body shape: a docstring,
if <test>: return <expr>guards, name aliases, and one finalreturn <expr>. The same operations run in the same order on the same operands, so the result is the same double, the samenaobject and the same exception. A body that grows past that shape simply stops being inlinable. math.max/math.minare varargs and get one written expansion for a fixed positional arity, guarded by a structural check of their real bodies.- Arguments are classified by what re-evaluating them costs. A constant, a plain name and a slot read emitted by the Series/Persistent lowering (
__state__[7]) run no user code; anything else must run exactly once and is bound with an assignment expression, or goes in directly when the body reads it once on a path that always runs. - The call being replaced evaluated every argument before entering the body. When an impure argument is present and the body would read the raising arguments out of source order, all of them are forced in front as
x is xprobes, in source order. With no impure argument nothing is forced — the only observable difference would be whichNameErroran undefined name reports. - A guard the call decides with a literal (
math.pow(x, 2)) is folded away at transform time, and so are the operations that guard reads (isinstance(2, NA),2 != 2,int(0)) — by running the same operation on the same literal, never by reasoning about it. Abooldoes not count as a numeric literal. - Free names of a copied body resolve through
pynecore.core.inline_support, not through the call site: a script’smathispynecore.lib.math, and a script may rebindabsorfloat. - The callee must be provably the library function: its dotted path is resolved through the module’s import map and compared by object identity. A name the module binds anywhere takes the whole base name out of the pass.
- Module level, class bodies, decorators, defaults, lambdas and comprehensions are skipped (the temporaries would bind in the wrong scope), as are keyword arguments, starred arguments and an unsupported arity. Leaving the call in place is always correct.
- Every comparison the pass emits for itself is marked exact, so the Float Tolerance rewrite leaves the copied raw
x == xna tests alone — but never a comparison inside an argument, which is the user’s own Pine code and still gets the tolerant rewrite. - The inlined wrapper sources and
core/inline_support.pytake part in the pipeline digest, so editing a wrapper body or an anchor invalidates cached script bytecode.
Function Isolation Transformer
The Function Isolation transformer ensures each function call site gets its own isolated state. The state of a callee instance lives in a dedicated child slot of the caller’s state vector, assigned at transform time.
Original code:
from pynecore.lib import ta, close
def main():
print(ta.sma(close, 12))Transformed code:
from pynecore import lib
import pynecore.lib.ta
from pynecore.core.instance_state import __resolve_slot__ as __resolve_slot·__
__pyne_slot_layout__ = {'main': {'init': (None,), 'series': (), 'varip': (), 'children': ((0, 'main·lib.ta.sma·0', False),), 'names': ('main·lib.ta.sma·0',)}}
def main(__state__):
print(lib.ta.sma(__st·__ if (__st·__ := __state__[0]) is not None else __resolve_slot·__(__state__, 0, lib.ta.sma), lib.close, 12))
main.__pyne_layout__ = __pyne_slot_layout__['main']Key aspects:
- The callee receives its own state vector as hidden first argument; after the first call it is a single list-index read
- Callees the transformer can prove stateful get this fast path; callees it cannot resolve at transform time go through a uniform binding path; stateless callees are called directly; builtins, types and module properties are left untouched
- Call sites in loops get one child state per iteration
The full routing logic, the loop emission and the runtime side are described on the Function Isolation page.
Input Transformer
The Input transformer processes input parameters and adds necessary ID information.
Original code:
@script.indicator
def main(source=lib.input.source(lib.close, "Source")):
result = source * 2Transformed code:
@script.indicator
def main(source=lib.input.source(lib.close, "Source", _id="source")):
source = getattr(lib, source, lib.na)
result = source * 2Key aspects:
- Adds _id parameter to input calls
- Adds getattr for source inputs at the start of functions
- Enables proper input parameter resolution
- Handles source inputs specially
Safe Convert Transformer
A Pine int is a double at run time (see Types — int), so the
transformer has two jobs: lower the float()/int() casts to their Pine meaning, and truncate a
Pine int to a real Python int at the places where Python itself insists on one.
Original code:
value = float(some_value)
number = int(another_value)
for i in range(array.size(a)):
total += weights[offset + i]Transformed code:
from pynecore.core import safe_convert
value = safe_convert.safe_float(some_value)
number = safe_convert.safe_int(another_value)
for i in range(safe_convert.native_int(array.size(a))):
total += weights[safe_convert.native_int(offset + i)]Key aspects:
float()becomessafe_float(),int()becomessafe_int(): both keepnaasna(anan), andsafe_int()returns the truncated value as a Pine int, i.e. afloat- Inside a
@pyne libmoduleint()becomesnative_int()instead: a lib computes its lengths, counts and ring indexes in nativeintand converts back only at its boundary - A
range()argument and the index (or slice bound) of a subscript are Python-native consumers: every one typed as a Pine int is wrapped innative_int(), a directint(x)index becomesnative_int(x)outright, and a folded literal such as2.0becomes2 - A
range()loop counter, anintliteral and a series buffer read (Series.__getitem__truncates on its own, and it is the hot loop) are left alone - Only adds the import when a lowering was actually emitted
Safe Division Transformer
The Safe Division transformer converts division operations to safe alternatives that handle division by zero like Pine Script.
Original code:
result = (close - open_) / (high - low)
ratio = value / divisor
constant = 1 / 2 # Literal division remains unchangedTransformed code:
from pynecore.core import safe_convert
result = safe_convert.safe_div(close - open_, high - low)
ratio = safe_convert.safe_div(value, divisor)
constant = 1 / 2 # Literal divisions are not transformedKey aspects:
- Converts division operations (/) to safe_div() calls
- Returns NA(float) instead of raising ZeroDivisionError
- Literal divisions (e.g., 1/2) remain unchanged for performance
- Matches Pine Script behavior where division by zero returns NA
- Only adds import if division operations are actually transformed
Example of Complete Transformation
Let’s see a full example of how a simple Pyne code is transformed:
Original Pyne Code:
"""
@pyne
"""
from pynecore import Series, Persistent
from pynecore.lib import script, ta, close, open, high, low, plot, color
@script.indicator("Example")
def main():
# Persistent counter
count: Persistent[int] = 0
count += 1
# Moving average calculation
ma: Series[float] = ta.sma(close, 14)
# Safe division that could cause division by zero
range_ratio = (close - open) / (high - low)
# Plot results
plot(ma, "MA", color=color.blue)
plot(count, "Count", color=color.red)
plot(range_ratio, "Range Ratio", color=color.green)Transformed Code:
"""
@pyne
"""
from pynecore import lib
import pynecore.lib.color
import pynecore.lib.ta
from pynecore.core.instance_state import __bind_any__ as __bind_any·__, __resolve_slot__ as __resolve_slot·__
from pynecore.core import safe_convert
from pynecore.core.instance_state import __attach_layout__
__pyne_slot_layout__ = {'main': {'init': (0, None), 'series': (), 'varip': (), 'children': ((1, 'main·lib.ta.sma·0', False),), 'names': ('count', 'main·lib.ta.sma·0')}}
@lib.script.indicator('Example')
@__attach_layout__(__pyne_slot_layout__['main'])
def main(__state__):
__state__[0] += 1
ma: float = lib.ta.sma(__st·__ if (__st·__ := __state__[1]) is not None else __resolve_slot·__(__state__, 1, lib.ta.sma), lib.close, 14)
range_ratio = safe_convert.safe_div(lib.close - lib.open, lib.high - lib.low)
lib.plot.plot(ma, 'MA', color=lib.color.blue)
lib.plot.plot(__state__[0], 'Count', color=lib.color.red)
lib.plot.plot(range_ratio, 'Range Ratio', color=lib.color.green)Worth noting in the output:
countbecame slot 0 of main’s state vector (initstarts with its initial value0).malost its Series annotation (never indexed — Unused Series Detector), so no series slot was allocated for it.- The
ta.smacall got child slot 1: the first call creates the callee’s state vector there, subsequent calls reuse it. - Since
mainis decorated, the layout attach uses the@__attach_layout__decorator form (innermost position, so it tags the raw function before other decorators wrap it). - The
plot(...)calls were routed to the module’s self-named function (lib.plot.plot) by the Module Property transformer and stay direct calls —plotis a function-and-namespace module.
This example demonstrates how the different transformers work together to convert a simple Pyne code into equivalent Python code that provides Pine Script-like behavior through PyneCore’s runtime system.
Debugging the Transformation
To see the transformed code of a script, use:
pyne debug ast my_script.pyor the PYNE_AST_DEBUG family of environment variables — see Debugging for details.