pineforge
エンジニアリング

シグナル時と約定時の証拠金チェック:TradingViewパリティの話

当方ブローカーは次バー始値でフィル時に証拠金ゲートを実行していたが、TradingViewは現在バー終値でシグナル時に実行する。その3セント差が2632トレード戦略から25トレードを静かに落とした。6本の隔離プローブがC++一行に収束するまで。

読了 約11分#parity#engine#tradingview#debugging#broker-emulation

パリティ・コーパスに含まれるコミュニティ製 Pine ストラテジー IES は、数週間にわたり TradingView 対比で moderate のままだった。件数は一致し、エントリー価格はセント単位で一致し、イグジット価格もセント単位で一致する。それでも 2,632 件のマッチしたトレードにわたり、累積 PnL は $29,281 乖離していた。

本稿は、その原因を突き止めた経緯だ。シグナル足の終値で見るか、次足の約定価格で見るか——わずか数セントの違いだけで、一度のマージン判定がトレードごと消える話である。

非対称な指紋

掘る前の 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 にあり、トレードごとの四則演算のどこかではなかった。

最初に試したこと(いずれも当たらなかった理由)

正しい仮説の前に、三つ外れた仮説を焼却した。ここを丁寧に残す価値は、デバッグの現実的な形を示すためだ。仕事の大半は仮説の肯定ではなく、殺すことだ。

インジケータのウォームアップ差。 エンジンは比較ウィンドウ開始前に 5 ヶ月分の OHLCV で TA スタックを温める。TradingView のチャート履歴はそこまで届かないかもしれない。バー単位の TA トレース用ハーネスを作り、その過程で八つの実在するインジケータ不具合を発見した — ta.pivothigh / ta.pivotlow の意味、ta.iii の式、ta.tsi のコード生成とスケール、ta.cog の重み、ta.bbw のパーセンテージ、ta.vwap の日次アンカー、ta.supertrend.lineta.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 のトレードをエントリー時刻で突き合わせ、qty 列を揃えれば、エクイティの食い違いを差分から直接読み取れる。

最初の八件のマッチしたトレード:

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 は完璧に一致した。双方とも $1M から出発した。トレード 2 以降、qty 列は分かれ、二度と収束しなかった。トレード 8 付近でギャップはおおよそ 7.76% に落ち着いた。

原因は計算ドリフトではなかった。取り逃しのトレードだった。本来トレード 2 になるはずの 2025-04-07 のエントリーは、TV のトレード一覧には載っていたが、エンジン側には現れなかった。TV はトレードを採択し $1,344 の損失を吸収し、エクイティはおおよそ $984k まで下がった。エンジンはトレードを取らず、エクイティはおおよそ $985k のままだった。そこから先、エンジンは以降の各トレードで TV より大きいサイズとなり、異なるエクイティパス上で損失が分岐し、13 ヶ月後に差は $29,281 になっていた。

幽霊の約定

strategy_entryprocess_pending_ordersenter_market_from_flatapply_market_order_fillstd::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=0

strategy.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=14

fill.kind=0FillEvaluation::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 多い 分を要求し、早期 return し、状態を一切更新しなかった。エラーも公開ログ行も出ない。ストラテジーから見れば、トレードは存在しなかったのと同じだ。

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 上回る。

Price usedRequiredEquityOvershoot
Signal close ($1566.66)$985,319.47$985,319.43+$0.04
Fill open ($1566.69)$985,338.34$985,319.43+$18.91

ETH の 3 セントの動きに 628.93 枚を掛けると、必要マージンは $18.91 ぶんだけ増える。当方エンジンのマージンチェックは、フィル価格を用いるフィル時刻で走り — 棄却された。TradingView のブローカーエミュレータは、我々の理解では、同じ検査をシグナル時刻のシグナル終値で走らせ — 採択した。$985k ポジションに対する 4 セントは、境界で TV が適用する浮動小数点の許容範囲内に収まると考えられる。

さらに三つのプローブで一点同定

一トレードの偶然ではないこと、かつ不具合が 1× エクイティ境界に特化していることを示すため、境界をはさむ三つのプローブを追加した。

ProbeSizingMargin pressure
parity-probe-04-percent-of-equity-sizingdefault_qty_type=percent_of_equity, default_qty_value=99~99% equity
parity-probe-05-small-equity-fractionqty = round(equity / close * 100) / 1000~10% equity
parity-probe-06-edge-margin-sizingqty = round(equity * 0.5 / close * 1000) / 1000~50% equity

三つとも TradingView に公開し、トレード一覧をエクスポートし、差分を走らせた:

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%
  -> excellent

PnL のドリフトは 2.49% から 0.78% へ。IESmoderate から excellent へ。コーパス全体では 168 中 165 が excellent、2 が strongmoderate は 0。ctest バイナリ 16 本すべて合格、codegen の pytest 313 件すべて合格、その他 161 のコミュニティ / 検証ストラテジーに回帰なし。

どんでん返し

修正後も parity-probe-03-equity-mirror 自体は TV と完全には一致しなかった。エンジンは 25 エントリー、TV は 24、重なりは 13 のみ。プローブ 03 は 1× エクイティ境界ぴったりに置く設計のため、二実装の許容差の食い違いは、他より先にそこに出る。

比較ウィンドウ内の各月曜について qty * signal_close - equity を計算し、TV の採択 / 棄却を記録した:

MondayOvershoot at signal closeTV action
2025-04-07+$0.04TAKE
2025-04-14-$0.76TAKE
2025-04-21-$0.19TAKE
2025-04-28+$0.34REJECT
2025-05-12-$0.18REJECT (under equity, yet rejected)
2025-05-26-$0.89REJECT (under equity, yet rejected)
2025-08-18+$0.24REJECT
2025-11-10+$0.24TAKE (identical overshoot, opposite outcome)

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 ポジションに対する 4 セントのオーバーシュートだった。本番ストラテジーは 1〜5% のマージン余裕(サイズ式の * 0.99* 0.95)を持たせ、そもそも境界に立たない方がいい。

5. 複利は単一トレードの取り逃しを増幅する。 04-07 の取り逃し単体は $1,344。13 ヶ月後にはおおよそ $29,000 — 元の取り逃しのおおよそ 22 倍 — に膨らんだ。各取り逃しがエクイティパスを変え、次のサイジングを変え、次の採択 / 棄却を変えるからだ。エクイティフィードバック型は、局所的な執行の穴に容赦ない。

6. バグが相手側にあることもある。そこは正直に文書化する。 TV の 1× 境界の挙動は、こちらから直せない本当の隅だ。黙って追いかけても揃わない指標を追うより、率直に記した方がいい。

方法論はこの不具合に限らない。パリティ・プローブは PyneCore 横断検証スイープの補完だ: PyneCore は他エンジンとの差を教え、パリティ・プローブは TradingView との差の理由を教える。新たなギャップを見つけたら、手順は今やこうだ: それを最も小さく再現する Pine を書き、TV に公開し、トレード一覧を差分し、エンジンに計測器を入れ、食い違いを直し、折り合えない TV の隅を文書化する。

次の一歩