실전에서 보는 Pine v6 타입 시스템
PineScript v6는 이름보다 강하게 타입이 잡혀 있습니다. 규칙, 추론, 함정 — 트랜스파일러를 출시하며 배운, 런타임에서 진짜 중요한 규칙들.
Pine Script는 느슨하게 타입이 붙는 언어라는 평판이 있습니다. 그 평판은 일부는 맞지만 대부분은 틀립니다. int가 조용히 float로 확장되고, na가 거의 모든 곳에서 허용되며, 언어가 명시적 어노테이션을 거의 요구하지 않기 때문에 그런 꼬리표가 붙었습니다. 하지만 그 관대한 표면 아래에는 실제로 물어뜯는 타입 시스템이 있으며, 한 번 정적으로 타입이 붙는 언어로 트랜스파일해야 할 때는 이빨 하나하나가 중요해집니다.
이 글에서는 Pine v6의 타입이 실제로 무엇인지, 추론 계층이 복잡함을 어디에 숨기는지, 그리고 C++ 코드 생성기를 만들면서 가장 자주 부딪친 세 가지 함정을 짚습니다.
평판과 현실
Pine을 막 시작한 프로그래머가 다음과 같이 씁니다.
x = 5
y = close + x어노테이션 없음. 컴파일러 불평 없음. 사람들은 “Pine에는 타입이 없다”고 결론내립니다. 하지만 Pine은 두 변수가 무엇인지 정확히 추론합니다: x는 simple int(컴파일 타임 상수 정수)이고, y는 series float(바마다 다른 부동소수 값)입니다. 추론은 실제로 일을 하고 있을 뿐, 눈에 보이지 않을 뿐입니다.
simple int와 series float의 구별은 장식이 아닙니다. 실행 시 동작, 연산자 호환 규칙, 트랜스파일 결과에서의 표현이 다릅니다. 언어는 타입이 있습니다. 다만 타입을 적도록 강요하지 않을 뿐입니다.
타입의 종류
Pine v6 타입 시스템에는 네 겹이 있습니다. 대부분의 사용자는 첫 번째만 만납니다.
원시 타입
원시 타입은 int, float, bool, string, color입니다. 아래에서 따로 다룰 na도 있습니다. 스칼라 값을 담는 리프 타입입니다.
int와 float는 암묵적 확장에 참여합니다. 한 식에서 섞이면 int가 float로 넓어집니다. 이 동작이 Pine에 “느슨한 타입” 라벨을 붙이게 하지만, 예외가 아니라 의도적이고 일관된 규칙입니다. 강한 타입 언어라면 수치 타입에 대해 같은 일을 합니다.
복합 타입
Pine v6는 세 가지 복합 타입을 지원합니다: array<T>, matrix<T>, map<K, V>. 모두 요소 타입으로 매개변수화되며, 그 요소 타입은 UDT를 포함한 Pine 타입이어야 합니다. array<float>, array<int>, 심지어 array<MyUDT>도 가능합니다.
복합 타입은 가변 객체입니다. 참조 타입이라는 의미에서, 함수에 array<float>를 넘기면 함수는 같은 저장소에 대한 핸들을 받습니다. 함수가 배열을 바꾸면 호출자도 변경을 봅니다.
사용자 정의 타입(UDT)
Pine v6는 type 키워드로 필드와 선택적 메서드를 가진 이름 있는 레코드 타입을 정의할 수 있게 했습니다.
type Order
float price
int qty
bool filled = false
method fill(Order this, float fillPrice) =>
this.price := fillPrice
this.filled := trueUDT는 구조체에 가장 가깝습니다. 필드 기본값을 둘 수 있습니다(filled = false처럼). 메서드는 점 표기로 호출되며 UDT 인스턴스가 this로 전달됩니다. 필드는 := 대입으로 변경 가능합니다.
형 한정자: series와 simple
Pine의 모든 값——원시 타입뿐 아니라 복합 타입과 UDT도——바 타임라인과의 관계를 나타내는 형 한정자를 짊어집니다.
series— 값이 바마다 달라질 수 있습니다. 히스토리가 있습니다:x[1]은 이전 바의x입니다. 가격 데이터가 들어가는 대부분의 식에서 기본입니다.simple— 전략 실행 내내 값이 고정됩니다. 유의미한 히스토리가 없습니다(simple값에 대해x[1]에 접근하는 것은 대부분의 맥락에서 컴파일 오류).const— 컴파일 타임에 알려진 값입니다. 예:const int VERSION = 6. 코드 생성에서 상수 접기로 사라지며, 컴파일 결과에는 나타나지 않습니다.
형 한정자는 계층을 이룹니다: const ⊂ simple ⊂ series. 서로 다른 형이 섞인 식은 가장 높은(가장 일반적인) 형으로 승격됩니다. simple int + series float는 series float입니다.
na 의미론
na는 보편적인 null이 아닙니다. 타입이 붙은 값이며 na<int>와 na<float>는 구별되고, 타입은 문맥에서 추론됩니다. 다음과 같은 식에서:
x = naPine은 기본적으로 x를 series float로 추론합니다(초기화되지 않은 시리즈에 가장 흔히 쓰이는 타입). 타입이 제한되는 맥락——예를 들어 나중에 x := 5를 쓰는 경우——에는 추론이 int로 고정됩니다.
na에 대한 산술은 전파됩니다: na + 1은 na입니다. 의도적이고 일관됩니다——값을 모르면 그 값에 의존하는 식도 모릅니다. 탈출구는 nz(x, 0)으로 na를 기본값으로 바꿉니다.
비교 연산자도 같은 규칙을 따릅니다: na == na는 true가 아니라 na입니다. 가장 자주 걸리는 함정입니다. 값이 빠졌는지 검사하려면 na(x)(내장 술어)를 쓰고, x == na는 쓰지 마세요.
우리 C++ 코드 생성기에서는 na가 std::optional<T>에 대응합니다. std::nullopt에 대한 산술은 std::nullopt를 만듭니다. na(x) 술어는 !x.has_value()가 됩니다. 의미는 깔끔하게 맞지만 C++에서는 더 장황합니다.
타입 추론 상세
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 : 0condition은 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++ 표현에 매핑해야 합니다.
원시 타입은 double, int64_t, bool, std::string에 대응합니다. Pine의 float는 항상 64비트이며, 우리는 전역적으로 double을 씁니다.
시리즈는 템플릿 지연 평가 히스토리 버퍼로 표현됩니다. 추상화는 다음을 노출합니다.
template <typename T>
class Series {
public:
T current() const;
T at(int bars_ago) const; // implements the [] operator
void advance(T next_value);
};Pine 코드가 close[2]를 쓰면 코드 생성기는 close.at(2)를 내보냅니다. 바가 끝나면 엔진은 모든 시리즈에 대해 advance()를 호출해 히스토리 창을 밉니다.
**na**는 std::optional<T>에 대응합니다. Series<std::optional<T>> 위의 산술 연산자는 Pine이 na를 전파하는 것처럼 nullopt를 전파합니다. 내장 nz()는 x.value_or(default_value)가 됩니다.
UDT는 평범한 struct가 됩니다. 필드는 멤버, 메서드는 멤버 함수입니다. Pine 필드 대입 연산자 :=는 평범한 C++ 대입이며 세터는 없습니다.
배열과 행렬은 각각 std::vector<T>와 std::vector<T>를 감싼 얇은 2차원 래퍼입니다.
우리가 가장 많이 겪은 세 가지 함정
1. series와 simple을 섞으면 series로 승격된다——상수일 거라 기대했을 때도
myLength = 14
ma = ta.sma(close, myLength)myLength가 전역에 선언되고 바뀌지 않는다면 코드 생성기가 컴파일 타임 상수로 다룰 거라 기대하기 쉽습니다. Pine은 이를 simple int로 추론하는데, 이는 const가 아닙니다——값은 컴파일 타임이 아니라 실행 시작 시 고정됩니다. 그 결과 ta.sma(close, myLength)는 series float이며 컴파일러가 접을 수 있는 형태가 아닙니다.
우리 코드 생성기에서는 성능을 위해 특정 윈도 크기를 상수 접기하고 싶었습니다. Pine 타입 시스템은 옳게 거부합니다: simple은 const가 아니고, const 키워드 없이 선언된 변수는 원칙적으로 input.int() 등으로 설정될 수 있어——컴파일이 아니라 시작 시점에 결정됩니다.
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++ 코드 생성기도 정확히 반영합니다: 필드 쓰기는 평범한 struct 멤버 대입입니다.
3. request.security는 기본이 인과적이다——하지만 lookahead 기본값은 버전마다 바뀌었다
htf_close = request.security("BTCUSDT", "D", close)이 호출은 타입상 올바르고 경고 없이 컴파일됩니다. 반환값은 현재 바의 시각 기준 일봉 종가입니다. 현재 바가 장중 15분 봉이라면 htf_close는 새 일봉이 닫힐 때까지 전날 종가를 유지합니다——인과적 해석입니다.
역사적 함정: Pine v4에서 request.security 기본값은 lookahead=barmerge.lookahead_on이었고, 일봉이 닫히기 전 미래 일봉을 엿볼 수 있었습니다. HTF 데이터를 쓰는 전략의 백테스트 결과를 부풀렸습니다. Pine v5는 기본값을 lookahead_off(인과)로 바꿨고 이것이 옳은 동작입니다. Pine v6는 v5 기본값을 유지합니다.
타입 시스템은 두 모드 모두 타입상 올바르기 때문에 경고하지 않습니다. PineForge 코드 생성기는 기본적으로 lookahead_off 의미를 적용해 Pine v6 참조 동작과 맞춥니다. v4에서 전략을 포팅했는데 PineForge에서 백테스트가 유달리 다르다면——원인은 종종 여기입니다.
Pine을 트랜스파일하며 우리가 더 엄격해진 이유
Pine 타입 규칙의 모든 모호함은 C++ 코드 생성기에서 결정이 되어야 합니다. 참조 문서가 “구현 정의”라고 하거나 두 동작이 모두 스펙과 기술적으로 맞을 때, 하나를 골라 희망에 맡길 수 없습니다. TradingView의 실제 출력과 비교해 TradingView가 어떤 분기를 탔는지 알아내고 그 분기를 구현해야 합니다.
이 과정은 어떤 사용자보다 Pine v6 언어 레퍼런스를 더 꼼꼼히 읽게 만들었고, 대부분의 패리티 테스트 케이스도 여기서 나왔습니다: request.security나 strategy.exit 모서리에서 코드 생성기가 TradingView와 다르면 최소 재현을 쓰고 validation 코퍼스에 넣은 뒤, 두 출력이 맞을 때까지 코드 생성기를 고칩니다.
타입 시스템에 모서리 케이스가 살아 있습니다. 형 한정자——series 대 simple 대 const——는 사소한 구현 디테일처럼 보이지만, 어떤 연산이 합법인지, 어떤 값을 어떤 함수에 넘길 수 있는지, 어떤 식을 바마다가 아니라 컴파일 타임에 평가할 수 있는지를 결정합니다. 이를 맞히는 것이 트랜스파일러와 좋은 트랜스파일러의 차이입니다.
다음에 볼 것
- Claude 또는 Cursor에서 코드 생성 API 사용 — 자신의 Pine v6 전략을 트랜스파일하고 로컬 OHLCV로 실행합니다. 타입 표현을 들여다보려면
transpile_pine이 C++ 출력을 돌려줍니다. - 갤러리 둘러보기 —
validation범주에는 타입 시스템 경계를 노리기 위해 설계된 142개 전략이 있습니다. 거래 건수는 어떤 전략이 오류를 드러내기에 충분한 활동을 하는지 알려줍니다. - 왜 이걸 만들었는지 읽기 — 엔진의 기원과 트랜스파일 대상으로 C++를 고른 이유.