본문으로 건너뛰기

실전에서 보는 Pine v6 타입 시스템

약 10분 읽기

PineScript v6는 이름보다 강하게 타입이 잡혀 있습니다. 규칙, 추론, 함정 — 트랜스파일러를 출시하며 배운, 런타임에서 진짜 중요한 규칙들.

Pine Script는 타입이 느슨한 언어라는 평판이 있습니다. 이 평판은 일부는 맞고 대부분은 틀립니다. int가 소리 없이 float로 넓어지고, na는 거의 어디서나 받아들여지며, 명시적인 타입 표기를 요구하는 일이 드물어서 붙은 꼬리표입니다. 하지만 그 너그러운 겉모습 아래에는 진짜 이빨이 있는 타입 시스템이 있고, 이를 정적 타입 언어로 트랜스파일해야 하는 순간 이빨 하나하나가 중요해집니다.

이 글에서는 Pine v6의 타입이 실제로 무엇인지, 추론 계층이 복잡함을 어디에 숨기는지, 그리고 Pine을 C++로 컴파일할 때 중요해지는 세 가지 함정을 살펴봅니다.

평판과 현실

Pine을 처음 쓰는 프로그래머가 이렇게 작성합니다.

x = 5
y = close + x

타입 표기도 없고, 컴파일러도 불평하지 않습니다. 그래서 이 프로그래머는 “Pine에는 타입이 없다”고 결론 내립니다. 하지만 Pine은 두 변수의 타입을 정확히 추론했습니다. x는 const int(컴파일 타임에 알려진 리터럴)이고, y는 series float(바마다 달라지는 부동소수점 값)입니다. 추론은 실제로 일을 하고 있습니다. 눈에 보이지 않을 뿐입니다.

const int와 series float의 구분은 겉치레가 아닙니다. 런타임 동작도, 연산자 호환 규칙도, 트랜스파일 결과에서의 표현도 다릅니다. 이 언어에는 타입이 있습니다. 타입을 적으라고 강요하지 않을 뿐입니다.

타입의 종류

여기서는 Pine v6의 타입 시스템을 네 층으로 나눠 살펴봅니다. 대부분의 사용자는 첫 번째 층만 접합니다.

원시 타입

원시 타입은 int, float, bool, string, color입니다. 아래에서 따로 다룰 na도 있습니다. 이들은 스칼라 값을 담는 말단(leaf) 타입입니다.

int와 float는 암묵적 확장(widening)의 대상입니다. 한 식에서 둘을 섞으면 int가 float로 넓어집니다. Pine에 “타입이 느슨하다”는 꼬리표를 붙인 게 바로 이 동작이지만, 특수한 예외가 아니라 의도적이고 일관된 규칙입니다. C++도 수치 타입에 대해 같은 일을 합니다.

복합 타입

Pine v6는 array<T>, matrix<T>, map<K, V> 세 가지 복합 타입을 지원합니다. 셋 다 요소 타입으로 매개변수화되며, 요소 타입으로는 UDT도 쓸 수 있습니다. 단, map의 키는 int나 string 같은 값 타입이어야 합니다. array<float>, array<int>는 물론 array<MyUDT>도 가능합니다.

복합 타입은 가변 객체입니다. array<float>를 함수에 넘기면 함수가 같은 저장소를 가리키는 핸들을 받는다는 의미에서 참조 타입입니다. 함수가 배열을 수정하면 호출한 쪽에서도 그 변경이 보입니다.

사용자 정의 타입(UDT)

Pine v5는 2022년 12월에 type 키워드를, 2023년 2월에 method 키워드를 추가했습니다. 둘을 함께 쓰면 필드와 선택적인 메서드를 가진, 이름 붙은 레코드 타입을 정의할 수 있습니다.

type Order
    float price
    int   qty
    bool  filled = false
 
method fill(Order this, float fillPrice) =>
    this.price  := fillPrice
    this.filled := true

UDT는 Pine에서 구조체에 가장 가까운 것입니다. 필드에 기본값을 둘 수 있습니다(filled = false처럼). 메서드는 점 표기법으로 호출하고, UDT 인스턴스를 첫 번째 매개변수로 받습니다. 위 예제에서는 이 매개변수의 이름이 this입니다. 필드는 := 대입으로 바꿀 수 있습니다.

형 한정자: series와 simple

Pine의 모든 값은 원시 타입뿐 아니라 복합 타입과 UDT까지, 바 타임라인과의 관계를 나타내는 *형 한정자(form qualifier)*를 갖습니다.

  • series — 값이 바마다 달라질 수 있습니다. 히스토리가 있어서, x[1]은 이전 바의 x 값을 돌려줍니다. 가격 데이터가 들어가는 식 대부분의 기본값입니다.
  • simple — syminfo.ticker처럼 첫 번째 바에서 값이 정해지고, 나머지 실행 동안 고정됩니다.
  • input — input.int() 계열 함수를 통해 스크립트 설정에서 값이 옵니다. 실행 내내 고정되며, 스크립트가 시작할 때 알려집니다.
  • const — 컴파일 타임에 값이 알려집니다. 모든 리터럴, 그리고 const int VERSION = 6처럼 선언한 것이 여기에 해당합니다. 코드 생성기는 이 값을 C++ 코드에 그대로 써 넣을 수 있습니다(방법은 아래 첫 번째 함정에서 보여 줍니다).

한정자는 const < input < simple < series라는 계층을 이루고, 식은 구성 요소 중 가장 강한 한정자를 따릅니다. simple int + series float는 series float입니다.

na 의미론

na는 만능 null이 아닙니다. Pine은 어떤 타입의 값이 비어 있는지 알아야 합니다. 그래서 다음 줄은 컴파일되지 않습니다.

x = na

타입을 추론할 근거가 없기 때문입니다. 대신 타입을 선언하세요.

float x = na

na에 대한 산술은 전파됩니다. na + 1은 na입니다. 의도된, 일관된 동작입니다. 값을 모르면 그 값에 의존하는 식도 알 수 없습니다. 빠져나갈 길은 nz(x, 0)으로, na를 기본값으로 바꿔 줍니다.

비교는 다르게 동작합니다. 피연산자 중 하나라도 na 값을 담고 있으면 ==, != 등 모든 비교 연산의 결과는 false가 됩니다. 그리고 x == na처럼 na 자체를 피연산자로 쓰면 컴파일되지 않습니다. 대신 내장 술어 na(x)를 쓰세요.

PineForge가 생성하는 C++에서 na는 변수 자신의 타입에 속한 센티널 값이고, na(x)는 is_na(x)가 됩니다. 생성된 비교 코드는 먼저 양쪽을 is_na로 확인하므로, na와의 비교는 false가 됩니다.

타입 추론 자세히 보기

Pine은 대입문의 오른쪽에서 타입을 추론합니다. 형 한정자 계층만 알면 규칙은 단순합니다.

a = 5           // const int
b = 5.0         // const float
c = close       // series float  (close is always series float)
d = close + 1   // series float  (int widens to float; series promotes)
e = bar_index   // series int    (bar_index is series int, always)

흥미로운 건 분기에 걸친 추론입니다.

x = condition ? close : 0

condition은 series bool일 수 있습니다. close는 series float이고, 0은 const int입니다. Pine은 0을 series float로 넓히고, 결과는 series float가 됩니다. 컴파일러는 언제나 가장 일반적인 형과 가장 포괄적인 타입을 고릅니다.

함수 인자도 추론을 제약합니다. 내장 함수가 simple int를 기대하는데 bar_index(series int)를 넘기면 컴파일 오류가 납니다. 타입 시스템의 엄격함이 드러나는 지점입니다. 시작 시점에 고정되어야 하는 매개변수에 바마다 바뀌는 값을 넘길 수는 없습니다.

C++ 코드 생성기가 이 타입들을 표현하는 방식

Pine의 타입 시스템을 C++로 옮기려면, Pine의 각 형과 타입을 런타임 동작이 올바른 C++ 표현에 대응시켜야 합니다.

아래 내용은 엔진 v1.0.0의 헤더, 그리고 codegen 1.0.0이 생성하는 C++ 코드와 일치합니다.

원시 타입은 C++ 스칼라에 대응합니다. float는 double, int는 int 또는 int64_t(bar_index, hour 등 계산으로 얻는 int 값 일부는 double에 담깁니다), bool은 bool, string은 std::string, color는 int64_t입니다.

Series는 히스토리를 담는 링 버퍼입니다. include/pineforge/series.hpp에 있는 엔진 클래스는 다음과 같습니다.

template <typename T>
class Series {
public:
    void push(T value);             // a new bar
    void update(T value);           // the same bar again (bar magnifier)
    T operator[](int offset) const; // offset bars back; na if out of range
    T current() const;
};

Pine 코드에서 close[2]라고 쓰면, 생성된 C++는 _s_close[2]를 읽습니다.

**na**는 값 자신의 타입에 속한 센티널입니다(include/pineforge/na.hpp). na<double>()은 quiet NaN, na<int>()는 가장 작은 int, na<bool>()은 false입니다. is_na(x)로 이를 검사하고, nz(x, y)는 인라인 is_na(x) ? y : x가 됩니다.

UDT는 핸들이 됩니다. 생성된 struct는 id를 담고, 필드는 전략이 소유한 레코드에 저장되며(그래서 체크포인트로 롤백할 수 있습니다), 각 메서드는 _udt_Acc_add(Acc self, double v)처럼 핸들을 받는 함수가 됩니다. 필드 쓰기는 레코드 필드에 대한 대입이고, 세터는 없습니다.

배열은 std::vector<T>가 됩니다. 행렬은 matrix<float>에는 엔진의 NumericMatrix(Eigen의 MatrixXd 기반)를, matrix<int>를 포함한 그 밖의 모든 요소 타입에는 GenericMatrix<T>를 씁니다. 생성된 코드는 여전히 PineMatrix와 PineGenericMatrix<T>라는 이름을 쓰는데, 둘 다 엔진 v1.0.0이 남겨 둔 별칭입니다.

세 가지 함정

1. 바뀌지 않는 값이라고 해서 꼭 const는 아니다

atrLen     = 14
fastLength = input.int(22, "Fast EMA Length")
atr  = ta.atr(atrLen)
fast = ta.ema(close, fastLength)

두 길이 모두 실행 중에는 바뀌지 않지만, 컴파일 타임에 알려진 것은 하나뿐입니다. atrLen은 const int입니다. 리터럴은 const이고, 리터럴로 초기화한 변수는 그 한정자를 물려받습니다. fastLength는 input int입니다. 값이 스크립트가 시작할 때 스크립트 설정에서 옵니다.

생성된 C++도 같은 선을 긋습니다. 공개 코퍼스에서 atrLen = 14 다음에 오는 ta.atr(atrLen)은 ta::ATR(14)로 컴파일되어, 14가 코드에 그대로 박힙니다. fastLength = input.int(22, "Fast EMA Length")는 get_input_int("Fast EMA Length", 22)로 컴파일되어, 백테스트가 시작될 때 값을 읽습니다. 덕분에 생성된 전략 하나를 서로 다른 입력으로 돌릴 수 있습니다. 로컬 MCP 서버의 backtest_pine_grid는 Pine을 한 번만 트랜스파일하고, 같은 C++ 코드로 입력값의 모든 조합을 돌립니다.

2. UDT 필드 대입은 메서드를 트리거하지 않는다 — 의도된 동작이다

type Box
    float value
 
method doubled(Box this) =>
    this.value * 2
 
b = Box.new(value = 10.0)
b.value := 20.0   // direct field write, no notification

객체 지향 언어라면 b.value := 20.0이 세터를 거치면서 doubled()를 호출하거나 다른 동작을 일으킬 거라고 기대할 수도 있습니다. Pine에서는 그렇지 않습니다. 필드는 공개되어 있고 직접 바꿀 수 있습니다. 메서드는 타입에 묶인 함수일 뿐입니다. UDT로 상태 기계를 만들면서 쓰기 시점에 검증하고 싶다면, 검증하는 메서드를 명시적으로 호출해야 합니다. 필드 쓰기는 모든 것을 건너뜁니다.

Pine 프로그램이 캡슐화가 있는 것처럼 보이지만 실제로는 없는 대표적인 지점입니다. 생성된 C++도 이를 그대로 반영합니다. 필드 쓰기는 중간에 메서드 없이 레코드 필드에 대입하는 것입니다.

3. request.security의 기본 동작은 인과적이다 — 하지만 lookahead 기본값은 버전마다 바뀌었다

htf_close = request.security(syminfo.tickerid, "D", close)

이 호출은 타입상 올바르고 경고 없이 컴파일됩니다. 반환값은 현재 바의 시각 기준 일봉 종가입니다. 현재 바가 장중의 15분짜리 바일 때, htf_close는 새 일봉이 마감될 때까지 전날 종가를 유지합니다. 이것이 인과적(causal) 해석입니다.

역사적인 함정이 하나 있습니다. Pine v2에서 security의 기본값은 lookahead=barmerge.lookahead_on이어서, 아직 마감되지 않은 미래의 일봉을 미리 엿볼 수 있었습니다. 이 때문에 HTF(상위 타임프레임) 데이터를 쓰는 전략의 백테스트 결과가 부풀려졌습니다. Pine v3에서 기본값이 lookahead_off(인과적)로 바뀌었고, 이것이 올바른 동작입니다. 이후 v6를 포함한 모든 버전이 이 기본값을 유지합니다.

두 모드 모두 타입상 올바르기 때문에 타입 시스템은 이에 대해 경고하지 않습니다. lookahead 인자가 없으면 PineForge 코드 생성기는 Pine v6 기본값에 맞춰 lookahead_off 의미론을 적용합니다.

Pine을 트랜스파일하면서 더 엄밀해진 이유

Pine 타입 규칙의 모호함은 하나하나가 C++ 코드 생성기에서 결정으로 바뀌어야 합니다. Pine 레퍼런스 문서가 “구현에 따라 다르다”고 하거나, 두 동작이 모두 기술적으로 스펙에 부합할 때, 아무거나 하나 골라 놓고 잘되길 바랄 수는 없습니다. TradingView의 실제 출력과 비교해 TradingView가 어느 쪽을 택했는지 알아내고, 그쪽을 구현해야 합니다.

이 과정 덕분에 우리는 어떤 사용자도 굳이 그러지 않을 만큼 Pine v6 언어 레퍼런스를 꼼꼼히 읽게 됐습니다. 공개 코퍼스의 전략 상당수도 이 과정에서 나왔습니다. request.security나 strategy.exit에서 코드 생성기가 TradingView와 어긋나는 코너 케이스를 찾으면, 최소 재현 전략을 작성해 코퍼스에 추가하고, 두 출력이 일치할 때까지 코드 생성기를 고칩니다.

코너 케이스는 타입 시스템에 모여 있습니다. series, simple, const 같은 형 한정자는 사소한 구현 디테일처럼 보이지만, 어떤 연산이 허용되는지, 어떤 값을 어떤 함수에 넘길 수 있는지, 어떤 식을 바마다가 아니라 컴파일 타임에 평가할 수 있는지를 결정합니다. 이걸 제대로 다루느냐가 그냥 트랜스파일러와 좋은 트랜스파일러를 가릅니다.

다음으로 볼 것