시그널 시점 vs 체결 시점 마진 검사: TradingView 패리티 이야기
브로커 에뮬레이터는 다음 봉 시가에 체결 시점 마진 게이트를 돌렸고, TradingView는 현재 봉 종가로 시그널 시점에 실행합니다. 그 3센트 차이가 2,632트레이드 전략에서 25트레이드를 조용히 없앴습니다. 여섯 개의 프로브가 한 줄의 C++으로 수렴하기까지.
패리티 코퍼스에 들어 있는 커뮤니티 Pine 전략 IES는
몇 주 동안 TradingView 대비 moderate 등급에 머물러 있었다. 체결 건수는 맞았고,
진입가는 센트 단위까지 일치했으며, 청산가도 센트 단위까지 일치했다. 그런데 누적 PnL은
2,632건의 매칭된 거래만으로도 29,281달러나 어긋났다.
이 글은 그 원인을 어떻게 찾았는지에 대한 이야기다. 요지는 마진 검사 시점이 네 센트만 늦게 도는 한 줄의 차이였다는 것이다.
비대칭 지문(asymmetric fingerprint)
파고들기 전에 verify_corpus.py가 이 전략에 대해 출력하던 내용은 다음과 같다.
community/IES (pre-fix)
Profile: strict
TV trades: 2668 (raw 2668)
Engine trades: 2663 (raw 2663)
Matched: 2632 (98.7% of TV)
Count delta: 0.1874% (OK)
Entry-price p90 delta: 0.0000% (OK)
Exit-price p90 delta: 0.0000% (OK)
PnL p90 delta: 2.5412% (X)
-> moderate이 한 줄만으로도 진단에 충분하다. 체결 수·진입가·청산가가 모두 맞는데 거래별 PnL이 2.5%쯤 흔들린다면, 시그널 로직 버그나 체결가 버그를 의심할 단계는 지났다는 뜻이다. 두 엔진은 같은 가격에 같은 거래를 고른다. 포지션 크기를 다르게 잡을 뿐이다.
비교 구간의 마지막 거래에서 거슬러 올라가 역산해 보면, 사이징 입력값의 갭이
누적 자본 29,281달러 차이와 정확히 맞아떨어진다. 괴리는 개별 거래의 산술이 아니라
strategy.equity — 모든 포지션 사이징 식이 의존하는 재귀적 피드백 변수 — 쪽에
깔려 있었다.
먼저 시도한 것들(그리고 왜 전부 빗나갔는지)
올바른 가설에 도달하기 전에 세 가지 잘못된 가설을 태워 없앴다. 실제 디버깅은 가설을 확인하는 일보다 박살 내는 일이 대부분이라는 점에서, 이 과정은 충분히 기록할 가치가 있다.
지표 워밍업 불일치. 엔진은 비교 구간이 열리기 전 다섯 달치 OHLCV로 TA 스택을
워밍업한다. TradingView 차트의 히스토리가 그만큼 길지 않을 수 있다. 바(bar) 단위
TA 추적 하네스를 만들고 그 과정에서 실제 지표 버그 여덟 건을 발견했다 —
ta.pivothigh / ta.pivotlow 의미, ta.iii 공식, ta.tsi 코드 생성과 스케일링,
ta.cog 가중, ta.bbw 백분율, ta.vwap 일간 앵커, ta.supertrend.line,
ta.pivot_point_levels 전봉 HLC, ta.cross 동점 건너뛰기 의미 등이다. 전부 고쳤지만
IES 지표에는 한 티끌도 반영되지 않았다.
스톱·리밋 체결 시점. 다른 커뮤니티 전략(scalping-wunder-bots)에서는 엔진이
TradingView보다 한 봄 일찍 청산하는 패턴이 뚜렷했다. 엔진이 진입 봉에서
strategy.exit 스톱을 평가하고 TV는 다음 봉으로 미룬다는 가설을 세웠고,
그걸 정확히 격리하려고 parity-probe-01-stop-limit-timing을 만들었다. 결과는
778건 중 778건 매칭, 네 가지 strict 게이트 모두 통과였다. 가설은 기각됐다.
CHoCH / BOS 스윙 추적. 또 다른 전략(VCP)에서는 2025-05-12 12:15에
가짜 진입 한 건이 있었고, 피벗 상태 진화가 엇갈렸을 거라고 의심했다.
피벗 시그니처를 그대로 복제한 parity-probe-02-choch-bos-isolator를 만들었다.
1026건 중 1026건 매칭. 역시 기각.
세 개의 프로브를 돌렸는데도 IES는 나아지지 않았고, 얻은 건 방법론 하나뿐이었다: 한 번에 가설 하나만 격리하는 최소 Pine 전략을 쓰는 쪽이, 700줄짜리 커뮤니티 스크립트 내부 변수를 쫓는 것보다 훨씬 빨랐다. 그래서 네 번째를 만들었다.
범인을 잡아낸 프로브
parity-probe-03-equity-mirror의 요령은 주문 수량 자체가 strategy.equity를
인코딩한다는 점이다.
//@version=6
strategy("Parity probe 03 - equity mirror",
shorttitle="par_p03", overlay=true, initial_capital=1000000,
default_qty_type=strategy.fixed, default_qty_value=1,
pyramiding=0, process_orders_on_close=false,
commission_value=0, slippage=0)
bool fire = dayofweek == 2 and hour == 0 and minute == 0
and strategy.position_size == 0
float qty_dyn = math.round(strategy.equity / close * 1000) / 1000
if fire and qty_dyn > 0
strategy.entry("E", strategy.long, qty=qty_dyn)
if strategy.position_size > 0 and bar_index >
strategy.opentrades.entry_bar_index(0)
strategy.close("E")qty = round(strategy.equity / close * 1000) / 1000처럼 소수 셋째 자리에서 반올림하면,
TradingView의 "List of Trades" CSV에 내보내지는 Size (qty) 열이 TV의
strategy.equity 값을 봉 단위로 알려주는 오라클이 된다. 엔진과 TV 거래를
진입 타임스탬프로 맞추고 수량 열을 나란히 놓으면, 자본 괴리를 diff에서 곧장 읽을 수 있다.
처음 여덟 건의 매칭된 거래는 다음과 같다.
2025-03-31 00:15 eng_qty=553.1490 tv_qty=553.1490 diff=+0.000%
2025-04-14 00:15 eng_qty=612.9970 tv_qty=612.1600 diff=+0.137%
2025-04-21 00:15 eng_qty=623.1630 tv_qty=622.3110 diff=+0.137%
2025-08-25 00:15 eng_qty=207.2020 tv_qty=209.8110 diff=-1.244%
2025-11-03 00:15 eng_qty=242.4760 tv_qty=251.9860 diff=-3.774%
2025-11-24 00:15 eng_qty=339.1820 tv_qty=361.0170 diff=-6.048%
2025-12-22 00:15 eng_qty=307.0180 tv_qty=330.5680 diff=-7.124%
2026-01-05 00:15 eng_qty=295.2650 tv_qty=320.1020 diff=-7.759%1번 거래는 완전히 일치했다. 양쪽 모두 100만 달러 자본에서 시작했다. 2번 거래부터 수량 열이 갈라졌고 다시 맞춰지지 않았다. 격차는 8번째 거래쯤에서 약 7.76% 부근으로 안정됐다.
원인은 계산 드리프트가 아니라 놓친 한 건의 거래였다. 원래 2번째 거래였어야 할 진입 — 2025-04-07 — 은 TV의 거래 목록에는 있었지만 엔진에는 없었다. TV는 그 거래를 받아들이고 1,344달러 손실을 감수했고, 자본은 대략 984k 달러 수준으로 내려갔다. 엔진은 그 거래를 하지 않아 자본이 대략 985k 달러에 머물렀다. 그 시점 이후로는 엔진이 TV보다 매 거래마다 더 크게 사이징됐고, 서로 다른 자본 궤적 위에서 누적 손실이 벌어졌으며, 13개월 뒤 격차는 29,281달러가 됐다.
유령 체결(ghost fill)
strategy_entry, process_pending_orders, enter_market_from_flat,
apply_market_order_fill에 std::cerr 로깅을 넣되, 환경 변수로 게이트해서
다른 테스트 실행에 소음이 새지 않게 했다.
시그널 봉, 2025-04-07 00:00:
[entry CALL] ts=1743984000000 bar=16236 id=E is_long=1 qty=627.312
pending_orders.size=0 pos_qty=0 pos_side=0strategy.entry가 호출됐다. 주문이 큐에 올랐다. 여기까지는 정상.
체결 봉, 2025-04-07 00:15:
[ppo] ts=1743984900000 bar=16237 pass=0 i=0 id=E type=0 is_long=1
qty=627.312 eligib=FILL_TRY
-> fill.kind=0 fill_price=1566.69
-> AFTER apply: pos_qty=0 pos_side=0 pyramid.size=0 trades.size=14fill.kind=0은 FillEvaluation::Kind::Fill이다. 가격 평가기는 체결하라고 했다.
apply_filled_order_to_state가 호출됐다. 그런데 포지션 상태는 갱신되지 않았다.
유령 체결.
이번에는 enter_market_from_flat 안쪽에, 마진 가드의 입력을 찍는 디버그 줄을
한 줄 더 넣었다.
[margin] ts=1743984900000 qty=627.312 fill_px=1566.69 margin_pct=100
required=982803 available_equity=982785 net_profit_sum=-17215.4
initial=1e+06 block=YES필요 마진: 982,803달러. 가용 자본: 982,785달러. 마진 가드는 계좌가 가진 것보다 18.91달러 더 필요하다고 보고, 상태를 전혀 갱신하지 않은 채 조기 반환했다. 에러도 없고 공개 로그 줄도 없었다. 전략 입장에서는 그 거래가 애초에 없었던 것과 같다.
TradingView가 같은 거래를 받아들인 이유
해당 봉 쌍의 OHLC는 다음과 같다.
04-07 00:00 (signal): O=1579.98 H=1585.57 L=1566.00 C=1566.66
04-07 00:15 (fill): O=1566.69프로브는 시그널 종가에서 수량을 맞췄다: qty = round(985319.43 / 1566.66 * 1000) / 1000 = 628.93. 시그널 종가에서 계산한 필요 마진은 가용 자본과
정확히 맞닿는다. 다음 봉 시가에서 계산하면 18.91달러를 초과한다.
ETH에서 세 센트 움직임에 628.93 계약을 곱하니 필요 마진이 18.91달러 더 필요해졌다. 엔진의 마진 검사는 체결 시점에 체결가를 써서 실행됐고 — 거부됐다. 우리가 이제 믿게 된 바로는 TradingView의 브로커 에뮬레이터는 시그널 시점에 시그널 봉 종가로 같은 검사를 돌렸고 — 985k 포지션에서 네 센트는 경계에서 TV가 적용하는 부동소수점 허용오차 아래라서 — 받아들인 것이다.
세 개의 프로브로 삼각측량
한 거래의 우연이 아니라는 것, 그리고 버그가 1× 자본 경계에 특화돼 있다는 것을 증명하려고 경계를 둘러싼 프로브를 세 개 더 만들었다.
셋 다 TradingView에 게시하고 거래 목록을 내보낸 뒤 diff를 돌렸다.
parity-probe-04: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0006% → excellent
parity-probe-05: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0698% → excellent
parity-probe-06: TV=57 engine=57 matched=57/57 (100.0%) PnL p90=0.0700% → excellent사이징이 1× 마진 경계에 박혀 있지 않을 때는, 각 프로브마다 57건 전부에서 엔진과 TV가 완전히 일치했다. 버그는 경계에서만 드러났다. 시그널 종가와 체결 시가 사이의 슬리피지가 마진 검사 결과를 뒤집을 수 있는 지점에서만 말이다.
수정
마진 검사를 enter_market_from_flat(체결 시점에 체결가를 쓰는 경로)에서
strategy_entry(시그널 시점에 현재 봉 종가를 쓰는 경로)로 옮겼다.
void BacktestEngine::strategy_entry(const std::string& id, bool is_long,
double limit_price, double stop_price,
double qty, ...) {
if (!trading_is_active(...)) return;
+
+ // TradingView broker rule: market-entry orders are admitted only
+ // when qty * <signal-bar close> * margin_pct/100 <= equity. Check
+ // happens HERE at signal time with current_bar_.close, NOT later
+ // in apply_market_order_fill where fill_price is the next bar's
+ // open. Verified empirically by parity-probe-{03..06}.
+ if (!std::isnan(qty) && std::isnan(limit_price) && std::isnan(stop_price)) {
+ double margin_pct = is_long ? margin_long_ : margin_short_;
+ if (margin_pct > 0.0 && !std::isnan(current_bar_.close)) {
+ double required = std::abs(qty) * current_bar_.close * (margin_pct / 100.0);
+ double available = current_equity();
+ double epsilon = std::max(1e-9, std::abs(available) * 1e-12);
+ if (required > available + epsilon) {
+ return;
+ }
+ }
+ }
...다섯 줄에 해당하는 추가분과, enter_market_from_flat에서의 대응 삭제.
IES에 대해 다시 돌린 결과는 다음과 같다.
community/IES (post-fix)
TV trades: 2668 (raw 2668)
Engine trades: 2643 (raw 2643)
Matched: 2618 (98.1% of TV)
Count delta: 0.9370% (OK)
Entry-price p90 delta: 0.0000% (OK)
Exit-price p90 delta: 0.0000% (OK)
PnL p90 delta: 0.7809% (OK) ← was 2.5412%
-> excellentPnL 드리프트는 2.49%에서 0.78%로 줄었고, IES는 moderate에서 excellent로 올라갔다.
코퍼스 나머지에서는 전략 168개 중 165개가 excellent, 2개가 strong, moderate는 0개.
ctest 바이너리 16개 전부 통과, codegen pytest 313케이스 전부 통과, 다른 커뮤니티·검증
전략 161개에서 회귀 없음.
반전
수정 후에도 parity-probe-03-equity-mirror 자체는 TV와 완전히 맞지 않았다.
엔진은 25번 진입했고 TV는 24번이었으며, 겹치는 것은 13건뿐이었다. 프로브 03은
정확히 1× 자본 경계 위에 서 있도록 설계돼 있어서, 두 구현 사이의 허용오차 불일치가
다른 어디보다 먼저 드러난다.
비교 구간의 각 월요일마다 qty * signal_close - equity를 계산하고 TV의
수락·거부 결정을 기록했다.
8월 18일과 11월 10일은 동일한 +$0.24 초과분인데 결과는 정반대다. 임계값이든 허용오차든 어떤 순수 마진 규칙으로도 이 패턴을 낼 수 없다. 이 경계에서 TV 브로커 에뮬레이터의 동작은 문서화된 Pine 변수 어디에도 보이지 않는 무언가에 달려 있는 것으로 보인다: 차트 로드 히스토리, 사용자가 게시할 때 전략에 봉이 어떤 순서로 보였는지, 우리가 관찰할 방법이 없는 숨은 내부 상태 등이다.
솔직한 해석은, 이것이 TradingView 브로커 에뮬레이터의 문서화되지 않은 모서리이며 사이징이 정확히 1× 자본 경계에 박혀 있을 때만 드러난다는 것이다. 실전 전략은 여유를 둔다 — community/IES는 거래당 자본의 1~1.4% 수준으로 사이징한다 — 그래서 실사용자에게는 거의 물지 않는다. 우리는 수정은 유지하고 프로브 03을 알려진 한계로 문서화하기로 했다. 진단용 프로브에서 겉보기 등급이 한 단계 내려가는 것은 IES 승리에 비하면 납득할 만한 대가다.
이 디버깅 이야기가 가르쳐 준 여섯 가지
1. 비대칭 지표 지문이 버그 클래스를 가리킨다. 체결 수·진입가·청산가는 맞는데 PnL만 흔들리면 사이징 버그다. 지표 공식을 감사하느라 몇 주를 쓰기 전에 사이징 관련 코드를 봤어야 했다.
2. 격리 프로브가 거대 전략 추적보다 확장에 유리하다. 가설 하나씩만 검사하는 작은 Pine 프로브 여섯 개로 이틀 만에 버그를 찾았다. 700줄짜리 커뮤니티 스크립트에서 자본 피드백 계산과 함께 내부 변수를 전부 추적했다면 더 오래 걸리고 소음만 더 컸을 것이다. 통합 테스트만 하고 단위 테스트를 안 쓰는 것과 같은 이치다.
3. TradingView의 qty= 인자는 시그널 시점 마진 의미를 갖는다. 검사는
strategy.entry가 호출된 봉을 기준으로 하지, 체결 봉을 기준으로 하지 않는다.
Pine 레퍼런스에는 이렇게 적혀 있지 않다. Pine 호환 브로커 에뮬레이터를 만든다면
맞춰야 하고, 자연스러운 구현인 “체결 시점에 체결가를 아니까 거기서 검사”는
소리 없이 깨진다.
4. 경계에서의 부동소수점은 늘 적이다. 29k 달러까지 불어난 복합 드리프트를 촉발한
단일 거래는 985k 포지션에서 네 센트 초과분이었다. 운영 전략은 사이징 식에 1~5% 마진
버퍼(* 0.99 또는 * 0.95 등)를 두어 경계 위에 서지 않는 편이 낫다.
5. 복리가 단일 거래 누락을 증폭한다. 04-07 누락 한 건만으로도 1,344달러였다. 13개월 뒤에는 약 29,000달러 격차로 불어났는데, 이는 대략 원래 누락의 22배다. 놓친 거래가 자본 궤적을 바꿨고, 그게 다음 사이징을 바꿨고, 그게 다음 수락·거부를 바꿨기 때문이다. 자본 피드백 전략은 실행에서 생긴 작은 틈에도 가혹하다.
6. 가끔은 버그가 “저쪽”에 있고, 솔직히 문서화할 때도 있다. 1× 경계에서의 TV 동작은 우리 쪽에서 고칠 수 없는 실제 모서리다. 쫓아도 맞출 수 없는 지표를 소리 없이 쫓는 것보다, 솔직히 적어 두는 편이 낫다.
방법론은 이 특정 버그를 넘어 일반화된다. 패리티 프로브는 PyneCore 교차 검증 스윕과 짝을 이룬다: PyneCore는 우리 엔진이 다른 엔진과 어디서 다른지 알려 주고, 패리티 프로브는 TradingView와 왜 다른지 알려 준다. 새 갭을 찾으면 이제 플레이북은 이렇다: 그걸 드러내는 가장 작은 Pine 스크립트를 쓰고, TV에 게시하고, 거래 목록을 diff하고, 엔진에 계측을 심고, 불일치를 고치고, 화해할 수 없는 TV 모서리는 문서에 남긴다.
다음에 볼 곳
- Claude나 Cursor에서 codegen API 써 보기 — 자신의 Pine 전략을 트랜스파일해
로컬 OHLCV에서 돌려 본다. 마진 검사가 어떻게 연결됐는지 보려면
transpile_pine도구가 C++ 출력을 돌려준다. - 갤러리 둘러보기 — 162개 전략과 현재 패리티 등급. community/IES 카드는
이제
excellent로 표시된다. - 얼리 액세스 신청 — 무료 티어는 월 100회 트랜스파일로, 자신만의 로컬 패리티 코퍼스를 시작하기에 충분하다.