Saltar a contenido

P19 · Chandelier Exit

Campo Valor
Familia Tendencia · stop recursivo con trinquete: dirección continua (siempre largo o corto tras el warmup, como P06, P14 y P18)
Features requeridas chandelier (primitiva nueva de fase B, entradas high, low, close; salidas long_stop, short_stop, direction), con dependencias paralelas atr, rolling_max y rolling_min
Parámetros y defaults period = 22, k = 3.0
Rejilla del catálogo period ∈ {10, 14, 22, 30}, k ∈ {2.0, 2.5, 3.0, 3.5} → 16 candidatos válidos, 16 recursiones pero solo 4 atr y 4 pares rolling_max/rolling_min
variant_keys k, period
Restricciones period ≥ 2 y k > 0 (si no, PolicyParamError)
first_decision_index period − 1 = 21 con defaults (29 en el peor candidato)
Activos y timeframes NVDA y KO en los 7 TF (1min1d); AMZN, INTC, MSFT, TSLA en 1d; BME en 1d; FX en 1d solo con el OHLC reparado
Paridad en la matriz PASS en NT_FEATURES, NT_INTENT_REPLAY y NT_ONLINE(vectorta) (12/12 cada una). UNSUPPORTED en NT_ONLINE(native) (12/12): "nautilus_trader 1.231.0 no ofrece un indicador nativo para 'chandelier'"

Fuentes: docs/estrategias/P19.md (especificación), catalog/strategy_catalog.json, docs/DATOS_POR_ESTRATEGIA.md, runs/parity/summary.json.

Qué vas a aprender

  • Cómo se construye un stop "colgado" del máximo reciente a k ATR de distancia, y qué es el trinquete que solo le deja subir.
  • Por qué la dirección se compara con los stops de la barra anterior y qué fuga aparece si usas los de la barra actual.
  • La precedencia alcista: el caso en que las dos condiciones se cumplen a la vez, con números.
  • A seguir tres reversiones de 2 unidades en el fixture de la especificación.
  • Por qué k entra en la spec de la feature (a diferencia de P16) y qué se comparte en una rejilla 4×4.

La idea en una frase

Mantente largo mientras el cierre no perfore un stop colgado del máximo reciente; mantente corto mientras no supere un stop apoyado en el mínimo reciente; al perforar, date la vuelta.

Intuición de mercado

El Chandelier Exit nació como regla de salida: "cuelga" el stop de un largo del máximo alcanzado (como una lámpara del techo, de ahí el nombre) a una distancia proporcional a la volatilidad (k · ATR). Si el precio sigue subiendo, el techo sube y el stop sube con él; si el precio cae más que k ATR desde el máximo, la tendencia se da por rota.

P19 lo convierte en un sistema completo siempre en mercado: hay un stop para el largo y otro para el corto, y cruzar el del lado contrario invierte la posición.

Analogía. Un escalador asegurado con una cuerda que solo se puede recoger, nunca soltar. Cada vez que sube, el seguro sube; si resbala, cae hasta el último seguro, no hasta el suelo. El trinquete es ese mecanismo de "solo recoger".

Por qué podría funcionar. Medir la distancia en ATR adapta el stop a la volatilidad del activo: en un activo tranquilo es estrecho; en uno nervioso, holgado. Eso evita que el ruido normal saque al sistema de una tendencia.

Cuándo falla.

  • Rangos laterales: el precio oscila entre los dos stops y cada perforación es una reversión de 2 unidades con coste.
  • Gaps: el stop se evalúa al cierre y el fill ocurre en la apertura siguiente; un gap grande puede dejar el fill muy lejos del stop.
  • k pequeño: stops estrechos, muchas reversiones. k grande: el stop corto puede quedar por encima del largo y la precedencia alcista domina.

Sesgos típicos al evaluarla.

  • Confundirla con un stop real: en SIM-S son señales al cierre; el stop intrabar con órdenes reales es otra estrategia (carril SIM-R, fuera de alcance).
  • Warmup distinto entre candidatos: period = 30 pierde 29 barras; compara desde un inicio común ≥ 29.
  • Sobreajuste de k: cuatro valores muy próximos; mira la estabilidad entre vecinos, no el mejor.

Las reglas exactas

La regla canónica del catálogo: "long_basic=HH(n)−k·ATR(n); short_basic=LL(n)+k·ATR(n). long_stop=max(long_basic,long_stop_prev) si C_prev>long_stop_prev, si no long_basic; short_stop=min(short_basic,short_stop_prev) si C_prevshort_stop_prev, −1 si C<long_stop_prev, si no conservar; objetivo=dirección."

Toda la recursión vive en la feature; la política solo traduce direction a objetivo:

Situación en el cierre de t direction[t] Objetivo Motivo
t < period − 1 NaN 0 WARMUP
t = period − 1 (semilla) +1 fijo +1 ENTRY
close[t] > short_stop[t−1] +1 (se evalúa primero) +1 REVERSAL / HOLD
si no, close[t] < long_stop[t−1] −1 −1 REVERSAL / HOLD
ninguna la anterior la anterior HOLD
barra no operable la feature sigue; se congela el publicado (D-10) NOT_TRADABLE
  • Trinquete: long_stop[t] = max(long_basic[t], long_stop[t−1]) solo si close[t−1] > long_stop[t−1]; si no, se reinicia a long_basic[t]. Simétrico para el corto con min y <. Desigualdades estrictas: el empate rompe el trinquete.
  • Salida: no hay salida a plano; la "salida" es la reversión.
  • Reversión: permitida, delta 2.
  • Precedencia alcista: si short_stop[t−1] < close[t] < long_stop[t−1], gana +1.
# feature (bucle obligatorio, recursiones acopladas)
hh, ll = rolling_max(high, n), rolling_min(low, n)      # ventanas INCLUSIVAS
atr    = RMA(TR, n)
long_basic  = hh - k*atr ;  short_basic = ll + k*atr
en fv = n-1:  long_stop = long_basic ; short_stop = short_basic ; direction = +1
para t > fv:
    long_stop[t]  = max(long_basic[t],  long_stop[t-1])  si close[t-1] > long_stop[t-1]  si no long_basic[t]
    short_stop[t] = min(short_basic[t], short_stop[t-1]) si close[t-1] < short_stop[t-1] si no short_basic[t]
    si   close[t] > short_stop[t-1]:  direction[t] = +1     # bullish primero
    elif close[t] < long_stop[t-1]:   direction[t] = -1
    else:                             direction[t] = direction[t-1]

# política: decisión en el cierre de t, fill en el open de t+1
target[t] = direction[t]   (NaN -> conservar)
si target != target_prev:  orden(target - target_prev) -> fill a open[t+1]

Stops de t−1, no de t

long_stop[t] ya incorpora high[t], low[t] y close[t] (a través de hh[t] y atr[t]). Comparar close[t] contra él es circular: produce un comportamiento suavizado y artificialmente bueno. El test de futuros alterados no lo caza (no usa futuro); la especificación le dedica un test propio.

Los indicadores que usa

Feature Fórmula first_valid (defaults)
rolling_max{n} max(high[t−n+1..t]), incluye t n − 1 = 21
rolling_min{n} min(low[t−n+1..t]), incluye t n − 1 = 21
atr{n} RMA de Wilder del TR, TR[0] = H[0] − L[0] n − 1 = 21
chandelier{n,k} bandas básicas + trinquete + dirección (arriba) n − 1 = 21

Las tres dependencias son paralelas (misma barra, mismo period): el calentamiento es el máximo, no la suma (R3 del contrato extendido, PARALLEL_DEPS). Sumarlas daría 42 y tiraría 21 barras buenas.

rolling_max/rolling_min no son donchian: donchian usa las barras estrictamente anteriores (es un nivel a superar, P11); aquí el máximo de hoy debe subir el stop hoy. En el lenguaje del catálogo son los rolling_high_low; las mismas primitivas las usa P08. La sonda de VectorTA clasifica chandelier_exit como VARIANT (solo devuelve el stop activo y usa SMA del TR): por eso la curva de los cuatro carriles es la canónica propia. Detalle en Las 23 primitivas de fase B.

Ejemplo barra a barra

Fixture §6 de docs/estrategias/P19.md: 16 barras, period = 3, k = 1.0 (los dos fuera de rejilla: con k ≥ 2 y period = 3 no hay ninguna reversión en 16 barras), cash_inicial = 1.000,00, tick 0,01, sin costes. first_decision_index = 2. Las barras 0 y 1 son calentamiento (TR 0,35 y 0,45); se muestran las barras 2–15:

t O H L C ATR long_stop short_stop dirección target orden fill pos. equity sin costes equity con 5 bps*
2 10,45 10,95 10,40 10,80 0,4500 10,5000 10,4000 +1 semilla +1 ENTRY BUY 1 0 1.000,00 1.000,00
3 10,80 11,20 10,75 11,05 0,4500 10,7500 10,6000 +1 +1 10,80 +1 1.000,25 1.000,24
4 11,05 11,15 10,80 10,90 0,4167 10,7833 10,8167 +1 +1 +1 1.000,10 1.000,09
5 10,90 10,95 10,55 10,60 0,4111 10,7889 10,9611 −1 −1 REVERSAL SELL 2 +1 999,80 999,79
6 10,60 10,70 10,15 10,20 0,4574 10,6926 10,6074 −1 −1 10,60 −1 1.000,20 1.000,18
7 10,20 10,30 9,90 9,95 0,4383 10,5117 10,3383 −1 −1 −1 1.000,45 1.000,43
8 9,95 10,05 9,65 9,70 0,4255 10,2745 10,0755 −1 −1 −1 1.000,70 1.000,68
9 9,70 9,85 9,50 9,55 0,4003 9,8997 9,9003 −1 −1 −1 1.000,85 1.000,83
10 9,55 9,95 9,50 9,80 0,4169 9,6331 9,9003 trinquete −1 −1 −1 1.000,60 1.000,58
11 9,80 10,25 9,75 10,15 0,4446 9,8054 9,9003 trinquete +1 +1 REVERSAL BUY 2 −1 1.000,25 1.000,23
12 10,15 10,55 10,10 10,45 0,4464 10,1036 9,9464 +1 +1 10,15 +1 1.000,55 1.000,52
13 10,45 10,60 10,25 10,30 0,4143 10,1857 10,1643 +1 +1 +1 1.000,40 1.000,37
14 10,30 10,40 10,00 10,05 0,4095 10,1905 10,4095 −1 −1 REVERSAL SELL 2 +1 1.000,15 1.000,12
15 10,05 10,20 9,85 10,00 0,3897 10,2103 10,2397 −1 −1 10,05 −1 1.000,20 1.000,16

Todo es literal del fixture salvo la última columna. *"Equity con 5 bps" es cálculo de este manual: escenario hypothetical_5bps (fee_rate = 0,0005, sin spread ni deslizamiento), comisión por fill = |delta| · precio · 0,0005 redondeada al céntimo half-even y restada de la caja en la barra del fill: fill 1 → 10,80 · 0,0005 = 0,0054 → 0,01; fill 2 → 2 · 10,60 · 0,0005 = 0,0106 → 0,01; fill 3 → 2 · 10,15 · 0,0005 = 0,01015 → 0,01; fill 4 → 2 · 10,05 · 0,0005 = 0,01005 → 0,01. Total 0,04: equity final 1.000,16 frente a 1.000,20.

Lo que hay que ver en la tabla:

  1. Barra 2: la semilla fija la dirección en +1 sin mirar nada: P19 entra largo en la primera barra válida. Fill en el open de 3 (10,80).
  2. Barra 5: close = 10,60 < long_stop[4] = 10,7833 → −1. Nótese que se compara con el stop de la barra 4; el de la 5 (10,7889) daría el mismo signo aquí, pero no en general. Fill de 2 unidades en el open de 6.
  3. Barras 10–11, el trinquete: close[9] = 9,55 < short_stop[9] = 9,9003, así que short_stop[10] = min(9,9169; 9,9003) = 9,9003. Sin trinquete el stop habría subido a 9,9169. En la 11, close = 10,15 > short_stop[10] = 9,9003 → reversión a +1.
  4. Barra 14: close = 10,05 < long_stop[13] = 10,1857 → −1, tercera reversión.
  5. Final: la posición corta queda abierta y se valora al último cierre (10,00): equity 1.000,20 sin liquidación automática.

Las cuentas de las barras clave

Semilla (t=2):   TR = 0,35 ; 0,45 ; 0,55  -> ATR[2] = (0,35+0,45+0,55)/3 = 0,4500
                 HH = max(10,30; 10,60; 10,95) = 10,95 ; LL = min(9,95; 10,15; 10,40) = 9,95
                 long_stop = 10,95 - 0,45 = 10,5000 ; short_stop = 9,95 + 0,45 = 10,4000 ; dir = +1
Reversión (t=5): close[5] = 10,60 < long_stop[4] = 10,7833   -> -1
Trinquete (t=10): close[9] = 9,55 < short_stop[9] = 9,9003   -> short_stop[10] = min(9,9169; 9,9003) = 9,9003
Reversión (t=11): close[11] = 10,15 > short_stop[10] = 9,9003 -> +1
Caja:            1.000,00 - 10,80   =   989,20  (BUY 1,  open[3])
                 989,20 + 2·10,60   = 1.010,40  (SELL 2, open[6])
                 1.010,40 - 2·10,15 =   990,10  (BUY 2,  open[12])
                 990,10 + 2·10,05   = 1.010,20  (SELL 2, open[15])
Equity final:    1.010,20 + (-1)·10,00 = 1.000,20

La precedencia alcista, con números

El fixture principal no la contiene. La especificación (§6.3) la construye con 5 barras, period = 3, k = 1.0 y un gap bajista (cierre 10,15 → apertura 8,00). En la barra 3: HH − LL = 10,25 − 7,90 = 2,35 > 2·k·ATR = 1,7667, así que el stop corto (8,7833) queda por debajo del largo (10,0500). En la barra 4, close = 9,20 cumple a la vez 9,20 > 8,7833 (alcista) y 9,20 < 10,0500 (bajista): gana +1 y la dirección pasa de −1 a +1. Con precedencia bajista no habría reversión: son dos estrategias distintas y por eso el catálogo obliga a registrar la elección. De propina, ese fixture tiene un empate exacto del trinquete (close[2] == short_stop[2] == 10,15), que lo rompe.

Diagrama

flowchart LR
    W(["WARMUP: t < period-1"]) -->|"semilla en period-1 · BUY 1"| L["Largo +1"]
    L -->|"close < long_stop[t-1] y no close > short_stop[t-1] · SELL 2"| S["Corto -1"]
    S -->|"close > short_stop[t-1] · BUY 2"| L
    L -->|"en otro caso"| L
    S -->|"en otro caso"| S

La condición de salida del largo excluye el caso "ambas": si el cierre queda entre los dos stops cruzados, gana el alcista y el largo se mantiene.

Cómo lanzarla

Backtest con ambos motores sobre NVDA diario (2015–2024), con los defaults. La estrategia se referencia por plantilla y parámetros (forma "inline" de la API de backtesting); el resto del payload sigue examples/api/01_backtest_p01_both.request.json:

BASE=http://localhost:8000
SID=$(curl -sS -X POST "$BASE/v1/backtest" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p19-0001",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": {
    "strategy": {"template_id": "P19", "params": {"period": 22, "k": 3.0},
                 "name": "Chandelier 22/3.0 base", "register": true},
    "data": {"asset_id": "NVDA", "timeframe": "1d",
             "window": {"start": "2015-01-01T00:00:00Z", "end": "2025-01-01T00:00:00Z",
                        "mode": "reset_flat"}},
    "execution": {"lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
                  "target_lots": [-1, 0, 1], "cost_scenario": "hypothetical_5bps"},
    "engine": "both",
    "cache": {"cache_mode": "WARM_FEATURES", "write_through": true},
    "output": {"output_mode": "metrics", "include_timings": true,
               "include_parity": true, "tail_rows": 5},
    "engine_options": {"vectorta": {"kernel": "scalar", "use_batch": true},
                       "nautilus": {"adapter": "NT_FEATURES"}}
  }}' | jq -r '.payload.signature.strategy_id')
echo "$SID"
import uuid, requests
BASE = "http://localhost:8000"
env = lambda payload: {"request_id": str(uuid.uuid4()), "correlation_id": None,
                       "schema_version": "v1", "payload": payload}
payload = {
    "strategy": {"template_id": "P19", "params": {"period": 22, "k": 3.0},
                 "register": True},
    "data": {"asset_id": "NVDA", "timeframe": "1d",
             "window": {"start": "2015-01-01T00:00:00Z",
                        "end": "2025-01-01T00:00:00Z", "mode": "reset_flat"}},
    "execution": {"lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
                  "target_lots": [-1, 0, 1], "cost_scenario": "hypothetical_5bps"},
    "engine": "both",
    "output": {"output_mode": "metrics", "include_parity": True},
    "engine_options": {"vectorta": {"kernel": "scalar", "use_batch": True},
                       "nautilus": {"adapter": "NT_FEATURES"}},
}
r = requests.post(f"{BASE}/v1/backtest", json=env(payload)).json()["payload"]
SID = r["signature"]["strategy_id"]          # "P19-xxxxxx": hash de plantilla + parámetros
print(SID, r["parity"]["verdict"])
for res in r["results"]:
    print(res["adapter"], res["run_metrics"]["final_equity"], res["run_metrics"]["n_fills"])

No pidas indicator_provider: "native" con NT_ONLINE: la matriz lo marca UNSUPPORTED porque Nautilus 1.231.0 no trae Chandelier.

Derivar una variante con un stop más holgado:

curl -sS -X POST "$BASE/v1/strategies/$SID/derive" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p19-derive", "correlation_id": null, "schema_version": "v1",
  "payload": {"param_overrides": {"k": 3.5}, "name": "Chandelier 22/3.5",
              "slug": "chandelier-22-3-5", "tags": ["sensibilidad"]}}' | jq '.payload.strategy_id, .payload.cache_forecast'

Según la tabla de invalidación (§5.3 de la especificación), cambiar k conserva atr(22), rolling_max(22) y rolling_min(22) (acierto en las dependencias) y rehace la recursión chandelier, la política y el ledger. Cambiar period solo reutiliza el tr.

Sweep de sus variant_keys (rejilla completa del catálogo, con la forma de 03_sweep_p01_4x4.request.json):

curl -sS -X POST "$BASE/v1/sweep" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p19-sweep", "correlation_id": null, "schema_version": "v1",
  "payload": {"base_strategy_id": "'"$SID"'",
    "grid": {"period": [10, 14, 22, 30], "k": [2.0, 2.5, 3.0, 3.5]},
    "candidate_policy": {"drop_invalid": true, "drop_duplicates": true,
                         "register_strategies": true, "name_template": "{template_name} {period}/{k}"},
    "data": {"asset_id": "NVDA", "timeframe": "1d",
             "window": {"start": "2015-01-01T00:00:00Z", "end": "2025-01-01T00:00:00Z", "mode": "reset_flat"}},
    "execution": {"lane": "SIM-S", "contract_version": "1", "initial_cash": 100000,
                  "cost_scenario": "hypothetical_5bps"},
    "engine": "vectorta", "cache": {"cache_mode": "OPT_FULL"},
    "output": {"output_mode": "metrics"},
    "engine_options": {"vectorta": {"kernel": "scalar", "use_batch": true}}}}' | jq '.payload.job_id, .payload.factorization_plan'

El resultado se consulta en GET /v1/jobs/{job_id}.

Cuánto cuesta la rejilla

Concepto Cuenta
Candidatos cartesianos 4 period × 4 k = 16
Inválidos 0
Recursiones chandelier 16: k entra en la recursión
Dependencias compartidas 4 atr + 4 pares rolling_max/rolling_min, cada una para 4 candidatos
Trabajo total de features 1 tr + 4 atr + 4 extremos + 16 recursiones = 25 cálculos

La parte cara (RMA y extremos rodantes) es la que se comparte; las recursiones son bucles escalares baratos. atr(10), atr(14), atr(22) y atr(30) son las mismas FeatureSpec que piden otras recetas (P06, P15, P16).

Qué mirar en los resultados

  • parity.verdict en NT_FEATURES: PASS en los 12 casos de la matriz. En KO 1d son 1.357 barras, 73 fills y first_decision_index = 21.
  • Número de reversiones y costs_total: cada reversión paga 2 unidades.
  • Exposición: ±1 en todas las barras operables desde la 21. Un 0 después es un fallo.
  • Fracción de barras con ambas condiciones (precedencia alcista activa): si crece con k, el candidato se está volviendo "siempre largo".
  • Distancia media al stop en ATR y duración media del tramo.
  • DATA_GAP_SESSIONS en KO (271 sesiones ausentes): el trinquete cruza el hueco sin reiniciarse; la primera barra tras un hueco puede invertir contra un stop fijado antes.

Causalidad comprobada

La especificación exige el test genérico de "futuros alterados" (400 barras; high/low/close[300:] desplazados −8,00; las seis series y los objetivos idénticos hasta la barra 250) y uno propio contra la fuga circular: comprobar que la dirección se decide con long_stop[t−1]/short_stop[t−1] y el trinquete con close[t−1]. Para continue, el checkpoint necesita el estado del ATR, las ventanas de period valores de high y low, los dos stops, la dirección, el cierre previo y el objetivo.

Errores típicos

Error Consecuencia Cómo evitarlo
Comparar con long_stop[t] en vez de [t−1] Fuga circular, resultados demasiado buenos Stops de la barra anterior
Trinquete con close[t] El stop deja de ser "el vigente al entrar" C_prev, literal del catálogo
Usar donchian (desplazado) Stop retrasado una barra: otra estrategia Ventanas inclusivas
Extremos del cierre en vez de high/low Stop más ceñido El catálogo lo prohíbe
ATR con SMA del TR Curva distinta (es la VARIANT de VectorTA) RMA de Wilder
Sumar los calentamientos 42 barras perdidas en vez de 21 Dependencias paralelas: máximo
Precedencia bajista Otra estrategia en barras reales Gana +1

Variantes y extensiones

  • Stop intrabar real (SIM-R): la versión con orden stop es otra estrategia con su propio id; no se compara con esta.
  • Solo largos: target_lots = [0, 1], usando el Chandelier como salida y no como reversión.
  • Periodos distintos para ATR y extremos: el catálogo fija el mismo n; separarlos sería otra entrada.
  • Parientes: P06 Supertrend (bandas recursivas sobre el precio medio), P14 PSAR (stop parabólico), P07 UT Bot.

Resumen

  • P19 cuelga un stop largo de HH − k·ATR y apoya uno corto en LL + k·ATR; el trinquete solo les deja moverse a favor mientras el cierre previo los respete.
  • La dirección compara el cierre con los stops de t−1; empieza en +1 por semilla y gana +1 si ambas condiciones se cumplen.
  • Siempre en mercado tras period − 1; cada reversión mueve 2 unidades.
  • En el fixture: entrada a 10,80, reversiones a 10,60, 10,15 y 10,05; equity final 1.000,20 (1.000,16 con 5 bps).
  • 16 candidatos → 16 recursiones sobre 4 ATR y 4 pares de extremos compartidos; paridad PASS salvo el nativo, UNSUPPORTED.

Para practicar

  1. Con el fixture, calcula ATR[4], long_basic[4] y short_basic[4] y comprueba 0,4167, 10,7833 y 10,8167. ¿Por qué long_stop[4] coincide con long_basic[4]?
  2. Rehaz la barra 5 usando por error long_stop[5] en la comparación. ¿Cambia algo aquí? Busca en la tabla una barra donde sí cambiaría.
  3. Lanza el sweep 4×4 sobre KO 1d y NVDA 1d. Compara el número de reversiones de (10, 2.0) y (30, 3.5) y el peso de los costes en cada uno.