pineforge
Ingeniería

Margen en la señal vs en el fill: una historia de paridad con TradingView

Nuestro emulador aplicaba el chequeo de margen en el fill con el open de la siguiente barra; TradingView lo hace en la señal con el cierre actual. 3 céntimos entre ambos precios eliminaron en silencio 25 operaciones de una estrategia de 2.632 trades. Cómo seis probes aislaron el bug en una línea de C++.

11 min de lectura#parity#engine#tradingview#debugging#broker-emulation

Una estrategia Pine comunitaria de nuestro corpus de paridad, IES, llevaba semanas etiquetada como moderate frente a TradingView. Los recuentos cuadraban. Los precios de entrada, al centavo. Los de salida, igual. Pero el PnL acumulado se desviaba $29.281 en 2.632 trades ya emparejados.

Esta es la historia de cómo encontramos la causa: un único control de margen que corría cuatro centavos de precio demasiado tarde.

La huella asimétrica

Así informaba verify_corpus.py antes de abrir el barranco:

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

Esa fila ya diagnostica la clase de bug. Cuando recuento y precios de entrada/salida coinciden pero el PnL por trade se va ~2,5 %, no estás ante un error de lógica de señal ni de precio de fill: ambos motores eligen los mismos trades a los mismos precios. Lo que cambia es cuánto abren.

Retrosolviendo desde el último trade de la ventana, la brecha en el input de sizing reproducía al céntimo los $29.281 de diferencia de equity acumulada. La divergencia vivía en strategy.equity — la variable recursiva de la que cuelga todo sizing con feedback — no en la aritmética trade a trade.

Lo que probamos primero (y por qué nada movió el needle)

Antes de acertar la hipótesis quemamos tres falsas. Vale la pena contarlas: en depuración real la mayor parte del tiempo se va en matar hipótesis, no en celebrarlas.

Calentamiento de indicadores. El motor precalienta la pila TA con cinco meses de OHLCV antes de abrir la ventana de comparación; el historial del gráfico en TV puede no llegar tan lejos. Montamos un harness de traza bar a bar y, de paso, corregimos ocho bugs reales de indicadores — semántica de ta.pivothigh / ta.pivotlow, fórmula ta.iii, codegen y escala ta.tsi, ponderación ta.cog, porcentaje ta.bbw, ancla diaria ta.vwap, ta.supertrend.line, HLC previo en ta.pivot_point_levels, y semántica ta.cross en empates. Arreglados los ocho, IES no se movió.

Timing de stops/límites. Otra estrategia comunitaria (scalping-wunder-bots) mostraba el motor saliendo una vela antes que TV. Hipotetizamos que evaluábamos strategy.exit en la barra de entrada y TV lo aplazaba. Construimos parity-probe-01-stop-limit-timing: 778/778 trades, cuatro gates estrictos limpios. Hipótesis falsada.

Seguimiento de swings CHoCH/BOS. En VCP sospechábamos una entrada espuria por divergencia en el estado de pivotes. parity-probe-02-choch-bos-isolator copiaba la firma: 1026/1026. Otra vez falsada.

Tres probes después: IES intacto, pero nueva metodología clara — estrategias Pine mínimas que aíslan una hipótesis vencen a tracear variables dentro de un script comunitario de 700 líneas. Escribimos un cuarto probe.

El probe que lo encontró

En parity-probe-03-equity-mirror la propia cantidad ordenada codifica 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(equity / close * 1000) / 1000 con tres decimales hace que la columna Size (qty) del CSV «List of Trades» de TV sea, bar a bar, un oráculo del strategy.equity que TV cree tener. Emparejas motor vs TV por timestamp de entrada, alineas qty y lees la divergencia de equity directamente del diff.

Los primeros ocho trades emparejados:

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%

El primero encaja perfecto; ambos arrancan con $1M. Desde el segundo las qty divergen y no vuelven a converger; hacia el octavo el hueco en qty se estabiliza ~7,76 %.

No era deriva de cálculo suave: era un trade perdido. La entrada que hubiera sido el trade 2 — 2025-04-07 — aparece en TV pero no en el motor. TV asume la operación y absorbe $1.344 de pérdida; equity ~$984k. El motor nunca entra; equity ~$985k. A partir de ahí el motor queda sobredimensionado en cada trade siguiente; las pérdidas compuestas siguen trayectorias distintas y trece meses después el hueco es $29.281.

Un fill fantasma

Instrumentamos std::cerr en strategy_entry, process_pending_orders, enter_market_from_flat y apply_market_order_fill, tras una variable de entorno para no inundar el resto de tests.

Barra de señal, 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

Se llamó strategy.entry; la orden quedó en cola. Hasta ahí bien.

Barra de fill, 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=0 es FillEvaluation::Kind::Fill. El evaluador de precio dice ejecutar. Se llama apply_filled_order_to_state. El estado de posición no cambia. Fill fantasma.

Otra línea, dentro de enter_market_from_flat, entrando al guardia de margen:

[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

Margen requerido $982.803, equity disponible $982.785. Faltan $18,91. El guardia sale antes de tocar estado; sin error público ni línea de log amigable. Para la estrategia, el trade no existió.

Por qué TradingView sí tomó el trade

OHLC del par de barras:

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

Si sizing en cierre de señal: qty = round(985319.43 / 1566.66 * 1000) / 1000 = 628.93. El margen requerido al close de señal roza la equity disponible (~cuatro céntimos por debajo del límite). Si recalculas al open del fill, el requerido supera la equity en $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

Tres céntimos en ETH × ~628,93 contratos → esos $18,91. Nuestro cheque corría en tiempo de fill con precio de fill; el emulador de broker de TV, según la evidencia empírica, en tiempo de señal con close de la barra de señal — y cuatro dólares sobre ~985k queda por debajo de la tolerancia FP que TV aplique en el borde.

Triangulación con tres probes más

Para demostrar que no era suerte de un solo trade — y que el bug vive en el borde 1× equity — añadimos:

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

Publicados en TV, exportados, 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

Fuera del pincho 1× equity, acuerdo perfecto en 57 trades por probe. El fallo solo aparece en el borde donde el slippage entre close de señal y open de fill puede voltear el chequeo.

El arreglo

Mover el margen desde enter_market_from_flat (fill time, precio de fill) a strategy_entry (signal time, current_bar_.close):

 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;
+            }
+        }
+    }
     ...

Más la eliminación duplicada en enter_market_from_flat. Re-ejecutamos 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 p90 de 2,49 % → 0,78 %. IES pasa a excellent. Resto del corpus: 165 de 168 en excellent, 2 en strong, 0 en moderate; ctest y pytest verdes; sin regresiones en las otras 161 estrategias comunitarias o de validación.

El giro

Tras el fix, parity-probe-03-equity-mirror sigue sin casar TV al 100 %: 25 entradas motor, 24 TV, solo 13 solapadas. El probe vive exactamente en el borde 1× equity; cualquier tolerancia distinta entre implementaciones salta ahí primero.

Por cada lunes en ventana calculamos qty * signal_close - equity y la decisión 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)

18 ago y 10 nov llevan el mismo overshoot +$0,24 con decisiones opuestas. Ninguna regla pura de margen — umbral o tolerancia única — reproduce ese patrón. En ese borde el emulador TV depende de algo no observable en variables Pine documentadas: historia de carga del gráfico, orden en que las barras se volvieron visibles al publicar, estado interno oculto.

Lectura honesta: esquina no documentada del broker TV cuando el sizing está clavado en el límite 1× equity. Las estrategias reales dejan colchón — community/IES opera ~1–1,4 % equity por trade — así que en práctica no muerde. Mantuvimos el fix y documentamos probe-03 como limitación conocida. Un downgrade cosmético en el probe diagnóstico vale la victoria en IES.

Seis lecciones

1. Huella métrica asimétrica = clase de bug: recuento + precios OK pero PnL drift → sizing, no indicadores.

2. Probes de aislamiento escalan mejor que tracear el monstruo comunitario de 700 líneas — misma lección que los tests unitarios frente solo a integración.

3. El parámetro qty= en entradas market tiene semántica de margen en tiempo de señal (barra de strategy.entry), no en la barra de fill. No está en la referencia Pine; un emulador «natural» que chequea en fill se rompe en silencio.

4. Punto flotante en el borde es el enemigo: cuatro céntimos sobre ~985k dispararon ~$29k compuestos. En producción deja buffer 1–5 % (* 0.99, * 0.95).

5. La composición amplifica un solo miss: el 04-07 fue ~$1.344; meses después ~22× por equity-feedback que redirige cada decisión siguiente.

6. A veces el bug está del otro lado: documentar el borde TV con franqueza vence a perseguir una métrica inalcanzable.

Los probes de paridad complementan el barrido PyneCore: PyneCore dice si divergimos de otro motor; los probes dicen por qué divergimos de TV. Playbook: script Pine mínimo → publicar en TV → diff → instrumentar → arreglar → documentar esquinas irreconciliables.

Siguientes pasos