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

  1. int arithmetic is double arithmetic. It drops the low digits above 2^53, and it never overflows.
  2. An int na prints NaN, propagates like a NaN, and fails x == x, exactly like a float na.
  3. The int type performs zero conversions at runtime. It does not truncate, not even when you assign to a variable you explicitly declared int.
  4. Truncation happens in the slots that consume a length or an offset, not at the type.
  5. TradingView never tells an int from a float at runtime. An array<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:

callint-typed 3.5float 3.5
str.tostring3.53.5
math.abs3.53.5
math.round44
array<int> push + get3.53.5
UDT int field3.53.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:

expressiontypeexpressiontype
d * 100intmath.max(d, 1)int
d * 1.0floatmath.max(d, 1.0)float
d + 1intmath.abs(d)int
d + 0.5floatd > 1 ? d : nint
d / 2intd > 1 ? d : 1.0float
d % 2intmath.round(d)int
-dintmath.sqrt(d)float
nz(d)intd[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:

expressionv5v6
const 5/222.5
series 5/22.52.5
const -5/2-2-2.5
const 7/233.5
int(5/2)22

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:

discriminatorwith 64-bit intsv3v4v5v6
(2^52*2+1) - 2^52*210000
123456789012345678 % 1080000
(2^62-1)+(2^62-1) > 0falsetruetruetruetrue

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.