A self contained interpreter for a practical subset of TradingView's Pine Script
(v3 through v6 style), written in plain Object Pascal with no third-party
dependencies. It compiles with Delphi (XE2 and newer) and with Free Pascal
3.2+ in Delphi mode, as a console program (PineRun) or as a DLL/shared
object (PineLib) callable from any language with a C FFI — see section 5.
The script is executed bar by bar, exactly like Pine does on a chart, so
close[1], var, ta.ema() and friends behave the way you expect.
The Pine Script code, the candle data and the settings are always three
separate inputs. The code is plain text (a .pine file, never wrapped in
JSON); the candles travel as a plain JSON array; everything else —
input() overrides, synthetic-data settings, the pretty-print flag —
travels as a small JSON object. The library gives back exactly one JSON
response: the plotted series, logs and alerts, or an "error" object if
something went wrong. Nothing else is ever printed or returned.
PineEngine/
├── src/
│ ├── Pine.Types.pas values, series, rolling buffers, call state
│ ├── Pine.Lexer.pas indentation aware tokenizer
│ ├── Pine.Ast.pas AST node classes
│ ├── Pine.Parser.pas recursive descent parser
│ ├── Pine.Runtime.pas bar data, plot output, execution context
│ ├── Pine.Builtins.pas built-in constants and function library
│ ├── Pine.Interpreter.pas tree walking evaluator
│ ├── Pine.Json.pas dependency-free JSON value/parser/serializer
│ ├── Pine.JsonApi.pas the library's public surface: RunPine()
│ └── Pine.DllApi.pas the DLL's public surface: PineRunJson() (see section 5)
├── demo/
│ ├── PineRun.dpr thin console wrapper around RunPine() (Delphi project)
│ ├── PineRun.lpr/.lpi the same program as a Lazarus/FPC project (includes PineRun.dpr)
│ ├── build.bat Delphi command line build (PineRun)
│ ├── build.sh Free Pascal command line build (PineRun)
│ ├── PineLib.dpr library project exporting PineRunJson/PineFreeString/PineVersion (Delphi project)
│ ├── PineLib.lpr/.lpi the same library as a Lazarus/FPC project (includes PineLib.dpr)
│ ├── build_dll.bat Delphi command line build (PineLib.dll)
│ └── build_dll.sh Free Pascal command line build (PineLib.dll / libPineLib.so)
├── samples/
│ ├── *.pine, candles_*.json, settings_*.json example scripts and their data
│ └── dll-clients/ calling PineLib from Delphi/FPC, C++, C#, and Python
├── assets/
│ └── pineengine-banner.png the banner image at the top of this README
└── README.md
Delphi (command line)
cd demo
build.bat
which is simply:
dcc32 -U..\src -E. -$Q- PineRun.dpr
(-$Q- turns off integer overflow checking for the build; one internal
routine relies on intentional Cardinal wraparound. The unit that needs it
also carries a local {$Q-} directive, so the switch is only a safety net.)
Delphi (IDE) — open demo\PineRun.dpr, then add ..\src to
Project ▸ Options ▸ Building ▸ Delphi Compiler ▸ Search path.
Free Pascal (command line)
cd demo
./build.sh # fpc -Mdelphi -Fu../src PineRun.dpr
Free Pascal / Lazarus (IDE) — open demo/PineRun.lpi in Lazarus and
build (Run ▸ Build, or Ctrl+F9). PineRun.lpi is a normal Lazarus
project; its source file, PineRun.lpr, is a one-line {$I PineRun.dpr}
rather than a second copy of the program, so the Delphi build and the
Lazarus build always compile from the exact same code - there's nothing
to keep in sync by hand. The .lpi already points its unit search path
at ../src.
The compiled PineRun takes the Pine Script code, the candles, the
settings, and an optional output path as four separate arguments, and
always prints one JSON response to standard output — nothing else,
ever (no usage banner, no progress messages, no plain-text errors; even
the "wrong usage" case prints a JSON error object).
PineRun <script.pine> [candles.json] [settings.json] [output.json]
<script.pine>— path to a plain text file with the Pine Script source. This is always plain code, never JSON.[candles.json]— optional. Either a path to a JSON file, or the JSON text itself typed directly on the command line. A plain JSON array of candles — nothing else lives in this argument. Omit it (or pass[]) to run on synthetic data instead.[settings.json]— optional. Either a path to a JSON file, or the JSON text itself. A JSON object withinput()overrides, the synthetic-data settings (used only when no candles were given) and the pretty-print flag.[output.json]— optional. A file path to also save the result to. The result is still printed to standard output either way; this just saves a copy alongside it. If the file can't be written (bad path, no permission...), a message goes to standard error — standard output stays pure JSON no matter what.
PineRun samples\sma_cross.pine "[]" samples\settings_sma_cross.json
PineRun samples\macd.pine samples\candles_macd.json > result.json
PineRun samples\rsi.pine "[]" "{\"syntheticBars\": 500}"
PineRun samples\state.pine :: all-synthetic run
PineRun samples\macd.pine samples\candles_macd.json "{}" result.json
- A plain array, one object per bar — not wrapped in
{"bars": [...]}. timemay be a JSON number (unix seconds) or a string ("2024-01-31"or a numeric timestamp as text); if omitted, the bar's index in the array is used.'','[]', or leaving the argument out entirely all mean "no candles — generate synthetic data instead" (seesyntheticBarsbelow).
{
"syntheticBars": 200, // only used when no candles were given
"seed": 12345, // seeds the synthetic random walk
"startPrice": 100,
"inputs": { "Fast Length": 8, "Use EMA": true, "Mode": "fast" },
"pretty": false // pretty-print the response (default: compact)
}Every field is optional — {}, an empty string, or leaving the third
argument out entirely all mean "all defaults".
inputskeys match thetitle=of the correspondinginput()call in the script (or"input" + <call index>if the script gives no title). Values keep their JSON type — a JSON bool reachesinput.bool()as a bool, a JSON number reachesinput.int()/input.float()typed accordingly, etc.
{
"title": "SMA Cross", "shortTitle": "SMA Cross",
"overlay": true, "isStrategy": false, "barCount": 120,
"plots": [
{ "title": "Fast MA", "kind": "plot", "color": "#FF2196F3",
"values": [null, null, 101.2, 101.4, ...] }
],
"logs": ["bar 45 [info]: ..."],
"alerts": ["bar 80: Golden cross"]
}values has exactly one entry per bar; null stands for Pine's na.
kind is "plot", "hline" or "bgcolor" (a bgcolor plot's values
are hex color strings instead of numbers).
{ "error": { "type": "syntax", "message": "Expected \")\" but found ... at line 7" } }type is one of "input" (malformed candles/settings JSON, or a usage
error such as a missing script file), "syntax" (script doesn't parse),
"runtime" (script parsed but failed while running), or "internal"
(anything unexpected).
The whole contract is one function that keeps code, candles and settings separate:
uses
Pine.JsonApi;
var
LResponseJson: string;
begin
LResponseJson := RunPine(
'indicator("t")' + sLineBreak + 'plot(close)', // plain Pine Script text
'[]', // no candles -> synthetic
'{"syntheticBars": 50}'); // settings only
Memo1.Lines.Add(LResponseJson); // always valid JSON, never raises
end;RunPine never raises: parse the result with your own JSON layer (or
Pine.Json.ParseJson) and branch on whether it has an "error" member.
Both the candles and the settings arguments default to '' (treated the
same as '[]' / '{}'), so RunPine(AScript) alone runs on 200 synthetic
bars with default settings.
If you are already inside Delphi/Pascal code and don't need the JSON
round-trip, the lower level API (Pine.Runtime, Pine.Interpreter) is
still available and works exactly as before:
uses
Pine.Types, Pine.Runtime, Pine.Interpreter;
var
LBars: TPineBarSet;
LCtx: TPineContext;
I: Integer;
begin
LBars := TPineBarSet.Create;
try
LBars.LoadFromCSV('prices.csv');
LCtx := RunPineScript(Memo1.Lines.Text, LBars); // inputs param optional
try
for I := 0 to LCtx.Output.PlotCount - 1 do
Memo2.Lines.Add(LCtx.Output.Plots[I].Title + ' = ' +
FormatNumber(LCtx.Output.Plots[I].ValueAt(LBars.Count - 1)));
finally
LCtx.Free;
end;
finally
LBars.Free;
end;
end;RunPineScript's third parameter is now a typed
TDictionary<string, TPineValue> of input overrides (matching what
Pine.JsonApi builds from the request's "inputs" object) instead of a
plain string list, so both entry points share exactly the same typing all
the way down to input.*().
All errors are raised as EPineSyntaxError / EPineRuntimeError
(both descend from EPineError) and carry the line number in .Message.
demo/PineLib.dpr builds the same interpreter as a DLL (Windows) or
shared object (Linux/macOS via Free Pascal), exporting three plain
cdecl C functions — the whole thing is also RunPine underneath, just
exposed across a C ABI instead of linked into a Pascal program:
char* PineRunJson(const char* script, const char* candlesJson, const char* settingsJson);
void PineFreeString(char* str);
char* PineVersion(void);
Build it:
cd demo
build_dll.bat :: Delphi -> PineLib.dll
./build_dll.sh # Free Pascal, command line -> PineLib.dll (Windows) or libPineLib.so (Linux/macOS)
or open demo/PineLib.lpi in Lazarus and build (Run ▸ Build). Like
PineRun.lpr, PineLib.lpr is a one-line {$I PineLib.dpr} rather than
a second copy of the library, so the Delphi and Lazarus builds always
compile from the exact same source.
The contract, documented in full in src/Pine.DllApi.pas:
- Every string crossing the boundary, in or out, is a UTF-8, null-terminated C string — the one format every language already knows how to produce and consume, with no COM/WideString/language- specific marshaling helper needed anywhere.
- The calling convention is
cdeclthroughout, the default for C/C++ and what every FFI layer below (ctypes, P/Invoke, ...) targets without extra ceremony. PineRunJsonandPineVersionallocate the string they return — callPineFreeStringon that exact pointer exactly once when done with it, never your own language'sfree/delete/GC. Strings you pass in are only read, never owned by the library; manage them however you normally would.PineRunJsonnever raises/throws and never returns null — a bad script, bad candles/settings JSON, or a runtime error all come back as a normal{"error": {...}}JSON string, exactly like the console program.
samples/dll-clients/ has a complete, runnable example in each of these
languages, all doing the same thing (print the library version, run a
small script, print the JSON result), so you can compare their output
directly:
| Language | File |
|---|---|
| Delphi or Free Pascal | pascal_client_example.dpr (one file, builds with either compiler) |
| C++ | cpp_client_example.cpp (dynamic loading via LoadLibrary/dlopen — no import library needed) |
| C# | CSharpClientExample.cs (P/Invoke) |
| Python | python_client_example.py (ctypes, standard library only) |
See samples/dll-clients/README.md for exact build/run commands for
each. The pattern is always the same: declare the three functions with
cdecl and UTF-8/C-string types, call PineRunJson, read the JSON, call
PineFreeString on the pointer you got back — and the identical
PineLib.dll/libPineLib.so works no matter which compiler built it,
since nothing about the calling side needs to know that.
- comments
//, the//@version=Nannotation - literals: numbers, strings,
true,false,na, colors (#FF9800), array literals[a, b, c]in expression position (e.g.input()'soptions=) - declarations
x = expr,var x = expr,varip, optional type prefixes (float x = 1.0), reassignmentx := expr - tuple declarations:
[macdLine, signalLine, hist] = ta.macd(close, 12, 26, 9) - chained simple statements on one line with commas:
a = f(x), b = g(y), c = a - b - operators
+ - * / %, comparisons,and/or/not, ternary? :, including ternaries and calls wrapped across several lines - history access
close[1],myVar[3],ta.sma(close, 10)[2], and history of a function parameter/local bound from a series (f(src) => src[1]), which resolves back to whatever global or bar-data field was passed in if / else if / elseas statement and as expressionfor ... to ... by ...,while,break,continue- user defined functions:
f(a, b) => exprand multi line bodies - named call arguments:
plot(x, title="MA", color=color.red) - Pine arrays:
array.new_<type>(),array.get/set/push/pop/shift/unshift/ size/clear/fill/includes/indexof/remove/insert- a real, mutable, by-reference array type (mutating it through one variable is visible through every other variable/parameter holding the same array) line,label,box, andtableobjects (line.new,box.new,table.new,table.cell, ... and their setters/delete) - tracked as real objects (so scripts that create/move/delete them run correctly) and reported in the JSON response; there is no chart to draw them on, so nothing is rendered, but a table's cells and a box's/line's coordinates are all there for the caller to render itself- method-call syntax:
myArray.push(x),myTable.cell(...),myLine.set_y1(v)- exactly equivalent toarray.push(myArray, x)etc. The namespace is inferred at evaluation time from the value's runtime type (array/line/label/box/table/string), since there's no static type system to know it ahead of time switch, both forms:switch subject \n pattern1 => result1 \n ... \n => defaultResultand the subject-less boolean form (switch \n cond1 => r1 \n cond2 => r2); each branch can be a single expression or a multi-line block like anif/function body- function parameter default values:
f(a, b = 10, c = na) => ... - compound assignment
x += e,-=,*=,/=,%=(sugar forx := x <op> e)
open, high, low, close, volume, time, hl2, hlc3, ohlc4,
bar_index, last_bar_index, na, obv, dayofweek, barstate.isfirst,
barstate.islast, barstate.isnew, barstate.isconfirmed, math.pi,
math.e, math.phi, color.* (16 named colors), line.style_*,
label.style_*, shape.*, location.*, dayofweek.sunday....saturday,
text.align_*, format.*, xloc.*, extend.*, display.*, size.*,
position.*, syminfo.ticker, syminfo.timezone, syminfo.mintick,
timeframe.period.
| Group | Functions |
|---|---|
| Moving averages | ta.sma, ta.ema, ta.rma, ta.wma, ta.vwma |
| Oscillators | ta.rsi, ta.macd, ta.stoch, ta.bb, ta.cci, ta.mfi |
| Statistics | ta.stdev, ta.variance, ta.highest, ta.lowest, ta.sum, ta.cum |
| Change / timing | ta.change, ta.mom, ta.roc, ta.barssince, ta.valuewhen, ta.rising, ta.falling |
| Pivots | ta.pivothigh, ta.pivotlow |
| Crosses | ta.crossover, ta.crossunder, ta.cross |
| Volatility | ta.tr, ta.atr |
| Sessions | time(timeframe, session, timezone) - returns the bar's own time when it falls in the given session ("1300-2200", optionally ":1234567" day codes), else na; handles overnight-crossing sessions and a "UTC±H[:MM]"-style timezone |
| Math | math.abs, max, min, pow, sqrt, round, floor, ceil, log, log10, exp, sign, avg, sin, cos, tan |
| Strings | str.tostring/tostring (including format.volume, e.g. "1.23M"), str.tonumber, str.length, str.upper, str.lower, str.contains, str.format, len (see note below) |
| Arrays | array.new_<type>, array.get/set/push/pop/shift/unshift/size/clear/fill/includes/indexof/remove/insert |
| Drawing | line.new/delete/set_x1/set_y1/set_x2/set_y2/set_xy1/set_xy2/set_color/set_width/set_style, label.new/delete/set_text/set_xy, box.new/delete/set_top/set_bottom/set_left/set_right/set_lefttop/set_rightbottom/set_bgcolor/set_border_color, table.new/delete/cell/cell_set_text/merge_cells |
| Output | plot, plotshape, plotchar, hline, bgcolor, alert, alertcondition, log.info/warning/error |
| Misc | na/na(x), nz, fixnan, int, float, bool, string, color.new, color.rgb, input.* (including input.session), indicator, study, strategy |
The ta. / math. prefix is optional, so both ta.sma(close, 10) and
sma(close, 10) work; study(...) is accepted as a v3/v4 alias of
indicator(...).
len(x)is not part of real Pine Script. It has been added here as a plain string-length convenience (equivalent tostr.length(x)) because scripts sometimes assume it exists. If you actually meant array length, usearray.size(x).
These are deliberate, so that the code base stays small and readable:
- no strategy engine (
strategy.entry, orders, backtest report) —strategy()is accepted but only stores the title - no
request.security(), no multi timeframe access - no matrices, maps, polylines, or user-defined types (arrays, lines,
labels, boxes, and tables are supported - see above); method-call syntax
(
x.method(...)) only works whenxis one of those or a string fill()is parsed and ignored;line/label/box/tableobjects are tracked and reported in the JSON response, but never actually drawn on anythingtime(timeframe, session, timezone)only understands a"UTC±H[:MM]"(or bare"UTC"/"GMT") timezone string - a named zone ("America/New_York") orsyminfo.timezone(which this interpreter always reports as"UTC", having no real symbol data) is treated as UTC, with no daylight-saving adjustment or exchange-holiday awareness- a local's history (
x[1]) only resolves whenx's value traces directly back to a global variable or a bar-data field (e.g. a function parameter the caller passed a series into, likef(src) => src[1]called asf(close)orf(someGlobal)); history of a genuinely computed local (y = a + b, theny[1]) still raises, since that value has no series to go back to varipbehaves likevarbecause there is no intra-bar (realtime) tick data- a stateful
ta.*call placed inside anifbranch only updates on the bars where the branch runs — TradingView warns about the same thing; the same caveat applies to a ternary's unchosen branch, which is still evaluated every bar (so a stateful call inside it keeps its state moving even when its result is discarded)
TPineLexerturns the source into tokens and emits virtualINDENT/DEDENT/NEWLINEtokens, because Pine blocks are defined by indentation. A logical line continues while a bracket is open, while a ternary's?is still waiting for its matching:(tracked as its own counter, independently of bracket depth - this is what lets a multi-line ternary keep going even when its "then" branch is itself a multi-line call that fully closes its own parens before the ternary's:shows up), or while the line ends with an operator that demands more (and/or/not, or any operator except the ones that always finish their own line:),],=>).TPineParserbuilds the AST and gives every node a uniqueSiteId. A dotted identifier chain (ta.sma,shape.labeldown) is only folded into one qualified name when its first segment is a known built-in namespace (seeCNamespaces); otherwise it's a plain variable, and a further.method(...)on it parses as aTMethodCallNodeinstead, resolved at evaluation time from the value's runtime type (array/line/label/box/ table/string) - this is what makesmyArray.push(x)work the same asarray.push(myArray, x).TPineInterpreterruns the whole AST once per bar. Global variables live inTPineSeriesobjects that keep the full history, sox[5]is a direct lookup. Stateful built-ins store their rolling buffers and previous values in theTCallStatebound to theirSiteId, which is why twota.ema()calls never mix their state. EveryTPineValuealso carries aSeriesTag: the name of the global/bar-data field it was last read from, copied along through local reads, ternaries, and function-parameter passing (but cleared by any arithmetic) - this is what lets a function parameter such assrcinf(src) => src[1]resolve its own history correctly no matter how many layers of calls it passed through.plot()writes into aTPinePlotthat is also bound to the call site, so the result is one output series perplot()statement;line.new()/label.new()/box.new()/table.new()similarly register aTPineLineObj/TPineLabelObj/TPineBoxObj/TPineTableObjwithTPineOutput, reported in the JSON response.Pine.JsonApi.RunPineis a thin façade on top of all of this: it takes the script text and the data JSON as two separate parameters, parses the data (via the dependency-freePine.Jsonunit), builds aTPineBarSetand a typed input-override map from it, callsRunPineScript, and serializesTPineContext.Outputback to JSON. Any exception raised anywhere in that pipeline is caught here and turned into an{"error": ...}response instead of propagating out.
Open Pine.Builtins.pas and add a branch to CallBuiltin:
if LFunc = 'zscore' then
begin
B := SmaStep(LSt, 0, LBar, A, LLen);
C := StdevStep(LSt, 1, LBar, A, LLen);
if IsNan(B) or IsNan(C) or (C = 0) then
Exit(PineNA);
Exit(PineFloat((A - B) / C));
end;Use a different buffer index per rolling calculation inside one function, and a
different Nums/Flags slot per recursive value — that is the whole contract.
It automatically becomes usable from JSON requests too, with no changes
needed anywhere else.

[ {"time": "2024-01-01", "open": 100, "high": 101, "low": 99, "close": 100.5, "volume": 1000}, {"time": "2024-01-02", "open": 100.5, "high": 102, "low": 100.1, "close": 101.7, "volume": 980} ]