Pine Script has no integers
Pine Script has an int type. It has array<int>, int fields in user defined types,
and function overloads that separate int from float. What it does not have is an
integer.
A Pine int behaves like an IEEE-754 double. It loses digits above 2^53, it never
overflows, and its na is a NaN. Nothing downstream of the compiler can tell it from a
float. The type exists and does real work, but it does all of that work before your
script runs.
I found this while building PyneCore, a Python runtime that runs Pine Script logic bar for bar. I can’t read TradingView’s source, so everything below is measured from Pine, with a script you can paste into the editor.
How the question came up
PyneCore represents Pine’s na as a real value, so the choice of representation
matters. Two things were already settled.
Pine v6 will not let a bool be na. Both of these fail to compile:
bool b = na // CE10173
bool c = close > close[1] ? true : na // CE10123
And a float na is a NaN, which is easy to confirm, because it carries the two
properties nothing else has:
float f = na
log.info(str.tostring(f)) // NaN
log.info(str.tostring(f == f)) // false
So bool has no na at all and float uses the hardware one. That leaves the obvious
question: what marks na in an int? A double has NaN built into the format. A 64-bit
integer does not, so a language needs to reserve a value for it, usually Long.MIN_VALUE.
I went looking for that sentinel.
There isn’t one, because there is no integer.
The claim
intarithmetic is double arithmetic. It drops the low digits above 2^53, and it never overflows.- An
intnaprintsNaN, propagates like a NaN, and failsx == x, exactly like afloatna. - The
inttype performs zero conversions at runtime. It does not truncate, not even when you assign to a variable you explicitly declaredint. - Truncation happens in the slots that consume a length or an offset, not at the type.
- TradingView never tells an
intfrom afloatat runtime. Anarray<int>will hold 3.5 without complaint.
Proving it
One detail matters before any of this reproduces. TradingView folds constant expressions at compile time in exact decimal, which hides the runtime behaviour completely. Every operand has to depend on something the compiler cannot know. The trick is a variable that is always zero but is not a constant:
int z = bar_index >= 0 ? 0 : 1
Add z to everything. Here is the whole proof:
//@version=6
indicator("Pine int is a double")
int z = bar_index >= 0 ? 0 : 1
if barstate.islast
// 1. Precision dies exactly at 2^53, like it does in a double
int a = 4503599627370496 + z // 2^52
int b = a * 2 + 1 // 2^53 + 1
log.info("2^53 + 1 = " + str.tostring(b))
log.info("(2^53+1) % 2 = " + str.tostring(b % 2))
log.info("18 digit literal= " + str.tostring(123456789012345678 + z))
// 2. No 64-bit wraparound: a long would overflow to negative here
int big = 4611686018427387903 + z // 2^62 - 1
log.info("big + big = " + str.tostring(big + big))
log.info("big + big > 0 = " + str.tostring(big + big > 0))
// 3. Long.MIN_VALUE is not a sentinel for na
int mn = -4611686018427387904 + z // -2^62
log.info("-2^63 = " + str.tostring(mn + mn))
log.info("na(-2^63) = " + str.tostring(na(mn + mn)))
// 4. int na is indistinguishable from float na
int ina = na
float fna = na
log.info("int na = " + str.tostring(ina) + " , +1 -> " + str.tostring(ina + 1))
log.info("float na = " + str.tostring(fna) + " , +1 -> " + str.tostring(fna + 1))
log.info("ina == ina = " + str.tostring(ina == ina))
log.info("fna == fna = " + str.tostring(fna == fna))
log.info("ina < 0 = " + str.tostring(ina < 0))
plot(1)
Output, FX:EURUSD 60m:
2^53 + 1 = 9007199254740992
(2^53+1) % 2 = 0
18 digit literal = 123456789012345680
big + big = 9223372036854776000
big + big > 0 = true
-2^63 = -9223372036854776000
na(-2^63) = false
int na = NaN , +1 -> NaN
float na = NaN , +1 -> NaN
ina == ina = false
fna == fna = false
ina < 0 = false
Line by line:
2^53 + 1 comes back as 9007199254740992, so the +1 fell off. That is the exact
point where a double runs out of mantissa. An 18 digit literal comes back rounded in the
last two digits for the same reason.
big + big is the one that closes the case. (2^62-1) + (2^62-1) is 2^63 - 2, which
in a signed 64-bit integer is -2. It is not negative. It printed 9223372036854776000,
which is 2^63 written with the shortest digit sequence that reads back as the same
double, padded out with zeros. Nothing wrapped. A fixed width integer would have.
na(-2^63) is false, so Long.MIN_VALUE is an ordinary value here, not a reserved
one. That rules out the sentinel I went looking for.
And the last five lines are the answer to the original question. int na prints NaN,
propagates through arithmetic as NaN, compares false against itself, and returns false
from <. That is not merely similar to a float na. I could not find a single test that
tells the two apart.
The type does nothing at runtime
This part surprised me more than the precision limit. The type tag does not force a single conversion, not even on assignment:
int z = bar_index >= 0 ? 0 : 1
int q = (7 + z) / (2 + z)
log.info(str.tostring(q)) // 3.5
A variable declared int holds 3.5, and the script compiles without a warning.
TradingView documents half of this on the
operators page: two
int values that do not divide evenly give you “a number with a fractional value”, with
5/2 = 2.5 as the example. What it does not say is that the fractional value then keeps
travelling under an int label for the rest of its life.
Take that 3.5 and a real float 3.5 through the same calls:
| call | int-typed 3.5 | float 3.5 |
|---|---|---|
str.tostring | 3.5 | 3.5 |
math.abs | 3.5 | 3.5 |
math.round | 4 | 4 |
array<int> push + get | 3.5 | 3.5 |
UDT int field | 3.5 | 3.5 |
array.new_int() stores 3.5. A user defined type with an int field stores 3.5. Nothing
downstream of the compiler is checking.
The truncation you expect does exist, but it lives at the other end, in the parameters that genuinely need a whole number:
int z = bar_index >= 0 ? 0 : 1
int len = (7 + z) / (2 + z) // int-typed, value 3.5
float sma_frac = ta.sma(close, len)
float sma_3 = ta.sma(close, 3)
float sma_4 = ta.sma(close, 4)
sma(close, len) = 1.1581366667
sma(close, 3) = 1.1581366667 (same)
sma(close, 4) = 1.15812 (different)
close[len] = 1.15807
close[3] = 1.15807 (same)
ta.sma and the history operator truncate toward zero when they receive the value. The
type never did.
So what is int even for?
At this point the type looks like decoration, and it is worth asking whether TradingView
could delete the keyword tomorrow and change nothing. It could not, because the entire
value of int is spent before the script ever runs.
Start with the compile error that the rest of this explains:
plot(ta.sma(close, 1.5))
// CE10123: An argument of "literal float" type was used
// but a "series int" is expected
The same call with a fractional int runs happily and quietly truncates to 3. So the
compiler is the only thing standing between you and a silently rounded length, and it
does that job with a type that has no runtime existence at all.
Overload resolution is the second job, and it is decided statically:
f(int x) => "INT impl"
f(float x) => "FLOAT impl"
int z = bar_index >= 0 ? 0 : 1
int i35 = (7 + z) / (2 + z) // int-typed, value 3.5
float f35 = (7.0 + z) / (2 + z) // float-typed, value 3.5
log.info(f(i35)) // INT impl
log.info(f(f35)) // FLOAT impl
log.info(f((14 + z) / (7 + z))) // INT impl
Two arguments with the identical runtime value of 3.5 reach two different
implementations, decided purely by the declared type. The third line is the same effect
from the other side: 14/7 is exactly 2.0, and it still picks the int overload,
because int / int stays int in the type algebra even though the value can be
fractional.
That algebra is consistent all the way through, and it propagates the way you would
expect from a language that does have integers. In the table below d is an int-typed
variable holding 14/8, so its value is 1.75, and n is an ordinary int variable:
| expression | type | expression | type |
|---|---|---|---|
d * 100 | int | math.max(d, 1) | int |
d * 1.0 | float | math.max(d, 1.0) | float |
d + 1 | int | math.abs(d) | int |
d + 0.5 | float | d > 1 ? d : n | int |
d / 2 | int | d > 1 ? d : 1.0 | float |
d % 2 | int | math.round(d) | int |
-d | int | math.sqrt(d) | float |
nz(d) | int | d[1] | int |
So int is a promise the compiler enforces about where a value is allowed to go: array
indices, loop bounds, lengths, history offsets, array.new_* sizes. Merging int and
float into one numeric type would take that checking away and leave you with silent
truncation everywhere.
The promise has a hole in it, which is the funny part. Division is not closed over
int, the compiler knows it, and it lets the fractional value through anyway.
TradingView already admitted this once
There used to be one place where Pine did real integer division. In v5, 5/2 was 2 or
2.5 depending on nothing but the qualifiers of the two operands:
| expression | v5 | v6 |
|---|---|---|
const 5/2 | 2 | 2.5 |
series 5/2 | 2.5 | 2.5 |
const -5/2 | -2 | -2.5 |
const 7/2 | 3 | 3.5 |
int(5/2) | 2 | 2 |
Same operator, same values, two different answers. The
v6 migration guide
covers this under “Fractional division of constants”, and does not defend it: “In v5, the
result of the division of two int values is inconsistent.” Two const operands gave you
integer division with the remainder discarded. One input, simple or series operand
among them gave the fraction back. v6 drops the distinction and always keeps the
fraction.
Notice where the old integer division lived. Not in the runtime, but in constant folding, which is a compile-time pass. Integer behaviour existed in Pine exactly as long as the compiler was the one doing the arithmetic, and v6 took even that away.
The v5 rule truncates toward zero rather than flooring, since const -5/2 is -2. That
is the same direction the length and offset slots truncate in, so at least the two
surviving pieces of integer behaviour agree with each other.
If you run old scripts, you inherit this. PyneCore needs a dedicated compiler pass to
reproduce v5 const division, because the same / has to mean two different things
depending on the version tag and on whether both operands folded.
Not new
The same three discriminators, each one guarded against constant folding, behave identically in v3, v4, v5 and v6:
| discriminator | with 64-bit ints | v3 | v4 | v5 | v6 |
|---|---|---|---|---|---|
(2^52*2+1) - 2^52*2 | 1 | 0 | 0 | 0 | 0 |
123456789012345678 % 10 | 8 | 0 | 0 | 0 | 0 |
(2^62-1)+(2^62-1) > 0 | false | true | true | true | true |
Those three are shaped the way they are because the old versions cannot print. log.info
does not exist before v5, so back there the only output channel is plot, and plot
cannot carry a large integer intact. So none of the discriminators prints a big number.
Each one collapses the large-value operation into a small result that survives the float
plot channel: a difference, a remainder, and a sign test, the last of which comes off the
plot channel as 1 rather than true.
The syntax drifts too. v4 has no indicator(), only study(), and v3 has neither
indicator() nor bar_index, so the constant-folding guard has to be built on n:
//@version=3
study("v3 discriminators")
z = n >= 0 ? 0 : 1
a = 4503599627370496 + z
b = a * 2 + 1
big = 4611686018427387903 + z
plot(b - a * 2, "d1") // 0, so the +1 was lost
plot((123456789012345678 + z) % 10, "d2") // 0, so the last digit was lost
plot(big + big > 0 ? 1 : 0, "d3") // 1, so nothing wrapped
The behaviour has never changed. The only thing v6 touched is the const division rule above, and that one lived in the compiler.
My guess at why, and it is only a guess: Pine started as a formula language over
floating-point series, and the type system arrived later as labels on top. A second
numeric kind would have meant duplicating the series, history and plotting layers, and
inventing a sentinel for na.
What the documentation leaves out
The type system page is precise about float. It gives you the internal precision,
“1e-16”, and it warns that comparison operators round their operands to nine fractional
digits.
There is no matching paragraph for int. No range, no maximum, no bit width, nothing
about what happens when a value gets too large. For a language that documents its float
down to the last decimal, that is a hard omission to miss, and it is also the only honest
one available. There is no separate int range to write down. The float paragraph already
covers it.
What this changes for you
Nothing about your RSI. Every number a normal script touches, bar_index, time in
milliseconds, lengths, offsets, sits far below 2^53, and the arithmetic is exact there.
It matters once you leave that range, which is easier than it sounds.
Do not build large synthetic IDs by multiplying values together, and do not scale timestamps to microseconds or nanoseconds. Past 9007199254740992 you lose the low digits, and you lose them silently. There is no overflow to catch, no wraparound to notice, just numbers that stop being the numbers you computed.
Do not assume int means whole. If the value came from a division and you care about it
being an integer, wrap it yourself in int(), math.floor() or math.round().
Do not assume a container of int holds integers. array<int> and int UDT fields will
store whatever you push into them.
Modulo, and where I got it wrong
Pine takes the sign of a % result from the dividend. (-7) % 2 is -1, 7 % (-2) is
1, and (-7.5) % 2 is -1.5. Python floors instead, and gives you the opposite sign on
the first two.
PyneCore was emitting a plain Python %. So on every negative operand it quietly
disagreed with TradingView, and it had been doing that for a long time before this
investigation went looking somewhere else entirely and tripped over it.
The reason it survived that long is worth knowing if you write Pine. Almost every % in
real code runs on non-negative values, cyclic buffer indices and bar_index % n, and
there the two definitions agree exactly. The disagreement only exists in the corner nobody
tests.
Reproducing all of it
Every number above came from a Pine script run on FX:EURUSD, 60 minutes. The v6 scripts
report through log.info rather than plot, because the plot channel would round the
large integers and hide the whole effect; drop them into the Pine editor, open the Pine
Logs pane, and you get the same lines. The version table is the exception, since log.*
does not exist that far back, and it reads its three collapsed discriminators off the plot
channel instead. The z guard against constant folding is mandatory everywhere.
I ended up here because PyneCore has to reproduce TradingView bar for bar, and getting
na right meant knowing how an int actually behaves. Pine acts like it has one numeric
type at runtime and two in the compiler, and the one you can’t see at runtime is doing
most of the work.