엔지니어링

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

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

약 10분 읽기#pine-script#types#language

Pine Script는 느슨하게 타입이 붙는 언어라는 평판이 있습니다. 그 평판은 일부는 맞지만 대부분은 틀립니다. int가 조용히 float로 확장되고, na가 거의 모든 곳에서 허용되며, 언어가 명시적 어노테이션을 거의 요구하지 않기 때문에 그런 꼬리표가 붙었습니다. 하지만 그 관대한 표면 아래에는 실제로 물어뜯는 타입 시스템이 있으며, 한 번 정적으로 타입이 붙는 언어로 트랜스파일해야 할 때는 이빨 하나하나가 중요해집니다.

이 글에서는 Pine v6의 타입이 실제로 무엇인지, 추론 계층이 복잡함을 어디에 숨기는지, 그리고 C++ 코드 생성기를 만들면서 가장 자주 부딪친 세 가지 함정을 짚습니다.

평판과 현실

Pine을 막 시작한 프로그래머가 다음과 같이 씁니다.

x = 5
y = close + x

어노테이션 없음. 컴파일러 불평 없음. 사람들은 “Pine에는 타입이 없다”고 결론내립니다. 하지만 Pine은 두 변수가 무엇인지 정확히 추론합니다: xsimple int(컴파일 타임 상수 정수)이고, yseries float(바마다 다른 부동소수 값)입니다. 추론은 실제로 일을 하고 있을 뿐, 눈에 보이지 않을 뿐입니다.

simple intseries float의 구별은 장식이 아닙니다. 실행 시 동작, 연산자 호환 규칙, 트랜스파일 결과에서의 표현이 다릅니다. 언어는 타입이 있습니다. 다만 타입을 적도록 강요하지 않을 뿐입니다.

타입의 종류

Pine v6 타입 시스템에는 네 겹이 있습니다. 대부분의 사용자는 첫 번째만 만납니다.

원시 타입

원시 타입은 int, float, bool, string, color입니다. 아래에서 따로 다룰 na도 있습니다. 스칼라 값을 담는 리프 타입입니다.

intfloat는 암묵적 확장에 참여합니다. 한 식에서 섞이면 intfloat로 넓어집니다. 이 동작이 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 := true

UDT는 구조체에 가장 가깝습니다. 필드 기본값을 둘 수 있습니다(filled = false처럼). 메서드는 점 표기로 호출되며 UDT 인스턴스가 this로 전달됩니다. 필드는 := 대입으로 변경 가능합니다.

형 한정자: series와 simple

Pine의 모든 값——원시 타입뿐 아니라 복합 타입과 UDT도——바 타임라인과의 관계를 나타내는 형 한정자를 짊어집니다.

  • series — 값이 바마다 달라질 수 있습니다. 히스토리가 있습니다: x[1]은 이전 바의 x입니다. 가격 데이터가 들어가는 대부분의 식에서 기본입니다.
  • simple — 전략 실행 내내 값이 고정됩니다. 유의미한 히스토리가 없습니다(simple 값에 대해 x[1]에 접근하는 것은 대부분의 맥락에서 컴파일 오류).
  • const — 컴파일 타임에 알려진 값입니다. 예: const int VERSION = 6. 코드 생성에서 상수 접기로 사라지며, 컴파일 결과에는 나타나지 않습니다.

형 한정자는 계층을 이룹니다: constsimpleseries. 서로 다른 형이 섞인 식은 가장 높은(가장 일반적인) 형으로 승격됩니다. simple int + series floatseries float입니다.

na 의미론

na는 보편적인 null이 아닙니다. 타입이 붙은 값이며 na<int>na<float>는 구별되고, 타입은 문맥에서 추론됩니다. 다음과 같은 식에서:

x = na

Pine은 기본적으로 xseries float로 추론합니다(초기화되지 않은 시리즈에 가장 흔히 쓰이는 타입). 타입이 제한되는 맥락——예를 들어 나중에 x := 5를 쓰는 경우——에는 추론이 int로 고정됩니다.

na에 대한 산술은 전파됩니다: na + 1na입니다. 의도적이고 일관됩니다——값을 모르면 그 값에 의존하는 식도 모릅니다. 탈출구는 nz(x, 0)으로 na를 기본값으로 바꿉니다.

비교 연산자도 같은 규칙을 따릅니다: na == natrue가 아니라 na입니다. 가장 자주 걸리는 함정입니다. 값이 빠졌는지 검사하려면 na(x)(내장 술어)를 쓰고, x == na는 쓰지 마세요.

우리 C++ 코드 생성기에서는 nastd::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 : 0

conditionseries bool일 수 있습니다. closeseries float입니다. 0const int입니다. Pine은 0series 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 타입 시스템은 옳게 거부합니다: simpleconst가 아니고, 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.securitystrategy.exit 모서리에서 코드 생성기가 TradingView와 다르면 최소 재현을 쓰고 validation 코퍼스에 넣은 뒤, 두 출력이 맞을 때까지 코드 생성기를 고칩니다.

타입 시스템에 모서리 케이스가 살아 있습니다. 형 한정자——seriessimpleconst——는 사소한 구현 디테일처럼 보이지만, 어떤 연산이 합법인지, 어떤 값을 어떤 함수에 넘길 수 있는지, 어떤 식을 바마다가 아니라 컴파일 타임에 평가할 수 있는지를 결정합니다. 이를 맞히는 것이 트랜스파일러와 좋은 트랜스파일러의 차이입니다.

다음에 볼 것

  • Claude 또는 Cursor에서 코드 생성 API 사용 — 자신의 Pine v6 전략을 트랜스파일하고 로컬 OHLCV로 실행합니다. 타입 표현을 들여다보려면 transpile_pine이 C++ 출력을 돌려줍니다.
  • 갤러리 둘러보기validation 범주에는 타입 시스템 경계를 노리기 위해 설계된 142개 전략이 있습니다. 거래 건수는 어떤 전략이 오류를 드러내기에 충분한 활동을 하는지 알려줍니다.
  • 왜 이걸 만들었는지 읽기 — 엔진의 기원과 트랜스파일 대상으로 C++를 고른 이유.