Saltar a contenido

P11 · Donchian / Turtle simplificado

Campo Valor
Familia Volatilidad-ruptura · ruptura de canal con dos canales asimétricos (entrada ancha, salida estrecha); alterna plano y posición, nunca invierte
Features requeridas donchian{entry} y donchian{exit} (primitiva donchian, entradas high, low; salidas upper, lower); la política lee además la columna close
Parámetros y defaults entry = 20, exit = 10
Rejilla del catálogo entry ∈ {20, 40, 55, 80, 100}, exit ∈ {5, 10, 20} → 15 solicitados, 14 válidos, 1 inválido, 7 canales distintos
variant_keys entry, exit
Restricciones enteros ≥ 1 y exit < entry (estricta; si no, PolicyParamError)
first_decision_index entry = 20 con defaults (100 en el peor punto de la rejilla)
Activos y timeframes NVDA y KO en los 7 TF (1min1d); AMZN, INTC, MSFT, TSLA en 1d; BME con reserva; FX solo con OHLC reparado
Paridad en la matriz PASS en NT_FEATURES, NT_INTENT_REPLAY y NT_ONLINE(vectorta) (12/12 cada una). PARITY_FAIL nativa en NT_ONLINE(native) (12/12), capa features: primera divergencia en la barra 32 de KO 1d, donchian_entry.upper 49,73 frente a 49,17; variante vta_inclusive_shifted frente a nt_inclusive_window (el Donchian nativo de Nautilus incluye la barra actual en la ventana). Es una variante declarada, no un bug

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

Qué vas a aprender

  • Por qué el canal de Donchian de P11 usa las barras estrictamente anteriores y por qué, si incluyera la barra actual, la estrategia no operaría nunca.
  • Qué significa "supera" y "rompe" en términos de desigualdades estrictas, y qué pasa cuando el cierre toca el canal.
  • Por qué romper el canal de entrada contrario implica siempre romper el de salida, y por qué la salida tiene prioridad (sin reversión).
  • A seguir en el fixture un largo, una salida, un corto y una señal final que no se ejecuta.
  • Por qué la rejilla 5×3 da 14 candidatos y solo 7 canales.

La idea en una frase

Comprar cuando el cierre supera el máximo de las últimas entry barras, vender en corto cuando rompe el mínimo, y salir cuando el cierre rompe el canal más corto de exit barras en contra.

Intuición de mercado

Un máximo de 20 barras es una frontera que el mercado ha respetado durante un mes (en diario). Cuando el cierre la supera, algo ha cambiado: hay compradores dispuestos a pagar más que en todo el periodo. El sistema de las Tortugas de los años ochenta convirtió esa idea en reglas mecánicas; P11 conserva solo el núcleo: ruptura de canal para entrar, canal más estrecho para salir.

Analogía. Un río que lleva un mes sin salirse de su cauce. El día que el agua supera la marca más alta del mes, es probable que llueva más arriba y la crecida siga. Para decidir que la crecida acabó no esperas a que baje del mínimo del mes: te basta con que baje del mínimo de la última semana y media.

Por qué podría funcionar. Las rupturas capturan el inicio de tendencias largas; la asimetría entrada ancha/salida estrecha corta las pérdidas antes de devolver toda la ganancia.

Cuándo falla.

  • Rangos con falsas rupturas: el cierre supera el máximo, se entra al open siguiente y el precio vuelve al canal.
  • Gaps en la apertura siguiente: la señal es al cierre, el fill al open[t+1], que puede estar muy por encima del canal.
  • Pocos trades: con entry = 100 sobre 1d hay pocos trades por década; cualquier métrica es ruido.

Sesgos típicos al evaluarla.

  • Confundir P11 con el sistema Turtle completo: no hay unidades múltiples, ni stop por N, ni filtro de "saltarse el trade tras uno ganador".
  • Ejecutar al precio del canal: suponer un stop colocado en el máximo que nadie colocó.
  • Canales que cruzan un hueco de datos (KO, 271 sesiones ausentes): el canal abarca años y suprime rupturas.

Las reglas exactas

La regla canónica del catálogo: "Canal de entrada: máximo H y mínimo L de las n barras estrictamente anteriores. Entrar largo si C supera el máximo y corto si rompe el mínimo. Salir largo al romper el mínimo de e barras anteriores; corto con máximo de e. Sin piramidación ni la regla histórica de saltarse trades ganadores."

Canales (spec §4.b): EU[t] = max(high[t−entry .. t−1]), EL[t] = min(low[t−entry .. t−1]), XU[t], XL[t] igual con exit. La barra t no entra.

Situación en el cierre de t Objetivo target[t] Motivo (reason)
t < first_decision_index 0 WARMUP
plano y C[t] > EU[t] +1 ENTRY
plano y C[t] < EL[t] −1 ENTRY
largo y C[t] < XL[t] 0 EXIT
corto y C[t] > XU[t] 0 EXIT
toque exacto (C == EU, C == XL…), sin condición o NaN el anterior HOLD
barra no operable (is_tradable = False) se congela el publicado NOT_TRADABLE
  • Entrada: solo desde plano; las cuatro desigualdades son estrictas (tocar no es superar).
  • Salida: prioritaria. Como exit < entry, siempre EL[t] <= XL[t], así que C < EL (entrada corta) implica C < XL (salida del largo). La barra que justificaría un corto primero cierra el largo.
  • Reversión: prohibida; |delta| siempre 1. La entrada contraria llega, si procede, en la barra siguiente desde plano.
  • Sin piramidación ni memoria del resultado del trade anterior.
# decisión en el cierre de t, fill en el open de t+1
target_prev = 0
para cada barra t:
    si t < entry:                                  target = 0            # WARMUP
    si no, si target_prev == +1:  target = 0 si C[t] < XL[t] si no +1
    si no, si target_prev == -1:  target = 0 si C[t] > XU[t] si no -1
    si no:                                          # plano
        si C[t] > EU[t]:          target = +1
        si no, si C[t] < EL[t]:   target = -1
        si no:                    target = 0
    si target != target_prev:
        orden(target - target_prev)  -> fill a open[t+1]    # nunca al precio del canal
    target_prev = target

El canal excluye la barra que lo rompe

Si el máximo incluyera high[t], como C[t] <= high[t] siempre, C[t] > max sería imposible: cero trades. El desplazamiento vive en la feature (first_valid = period, no period − 1), no en la política. Es la advertencia de engineering_notes del catálogo y el test de causalidad específico de P11.

Los indicadores que usa

Feature Fórmula first_valid
donchian{p}.upper max(high[t−p .. t−1]) (rebanada abierta por la derecha) p
donchian{p}.lower min(low[t−p .. t−1]) p

first_decision_index = max(entry, exit) = entry (porque exit < entry) = 20 con defaults. El canal de salida es válido antes (en exit), pero no adelanta la primera decisión: con el estado en 0 las salidas son irrelevantes.

No confundir con rolling_max/rolling_min, que son inclusivas (first_valid = p − 1) y las usan P08, P10 y P19. Más detalle en Las 23 primitivas de fase B y en Indicadores y features.

Ejemplo barra a barra

Fixture §6 de docs/estrategias/P11.md: entry = 4, exit = 2 (fuera de rejilla a propósito; cumplen exit < entry), 16 barras, cash_inicial = 1.000,00, tick 0,01, sin costes. first_valid(donchian{4}) = 4, first_valid(donchian{2}) = 2first_decision_index = 4.

Barras 0–2 omitidas (canal de entrada NaN; en la 2 ya existe el de salida: XU 102,00, XL 99,00).

t O H L C EU EL XU XL target orden fill pos. PnL acum. sin costes equity con 5 bps*
3 100,80 102,50 100,50 101,50 NaN NaN 102,00 99,50 0 WARMUP 0 0,00 1.000,00
4 101,20 103,00 101,00 102,50 102,50 99,00 102,50 99,50 0 HOLD (toque) 0 0,00 1.000,00
5 102,50 105,00 102,00 104,00 103,00 99,50 103,00 100,50 +1 ENTRY BUY 1 0 0,00 1.000,00
6 104,50 106,00 103,00 105,00 105,00 99,50 105,00 101,00 +1 HOLD 104,50 +1 0,50 1.000,45
7 103,50 104,00 101,00 102,00 106,00 100,50 106,00 102,00 +1 HOLD +1 −2,50 997,45
8 101,50 102,00 99,00 100,00 106,00 101,00 106,00 101,00 0 EXIT SELL 1 +1 −4,50 995,45
9 99,50 100,00 97,00 98,00 106,00 99,00 104,00 99,00 −1 ENTRY SELL 1 99,50 0 −5,00 994,90
10 98,50 99,00 96,00 97,00 106,00 97,00 102,00 97,00 −1 HOLD 98,50 −1 −3,50 996,35
11 97,50 98,00 95,00 96,00 104,00 96,00 100,00 96,00 −1 HOLD −1 −2,50 997,35
12 96,50 97,00 94,00 95,00 102,00 95,00 99,00 95,00 −1 HOLD −1 −1,50 998,35
13 96,50 99,00 96,00 98,00 100,00 94,00 98,00 94,00 −1 HOLD (toque) −1 −4,50 995,35
14 98,50 101,00 98,00 100,00 99,00 94,00 99,00 94,00 0 EXIT BUY 1 −1 −6,50 993,35
15 100,50 103,00 100,00 102,00 101,00 94,00 101,00 96,00 +1 ENTRY BUY 1 (sin fill) 100,50 0 −7,00 992,80

OHLC, canales, objetivos, motivos, los cuatro fills (5→6 a 104,50; 8→9 a 99,50; 9→10 a 98,50; 14→15 a 100,50) y la equity en las barras 6–10, 12 y 15 son literales del fixture; el resto del PnL es caja + pos · close − 1.000,00 con la caja del fixture. *"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 en la barra del fill:

  • fill 1 (barra 6): 104,50 · 0,0005 = 0,05225 → 0,05;
  • fill 2 (barra 9): 99,50 · 0,0005 = 0,04975 → 0,05;
  • fill 3 (barra 10): 98,50 · 0,0005 = 0,04925 → 0,05;
  • fill 4 (barra 15): 100,50 · 0,0005 = 0,05025 → 0,05.

Total 0,20; equity final 993,00 − 0,20 = 992,80. La orden BUY decidida en la barra 15 no se llena: no existe open[16].

Lo que hay que ver en la tabla:

  1. Barra 4: C = 102,50 == EU = 102,50. Tocar no es superar: no entra.
  2. Barra 5: C = 104,00 > EU = 103,00 = max(high[1..4]). Con ventana inclusiva el canal sería 105,00 (el high[5]) y la entrada no existiría.
  3. Barra 8: enter_short y exit_long ciertos a la vez (la misma comparación: EL[8] = XL[8] = 101,00). Manda la salida: objetivo 0, no −1. El corto llega en la barra 9, desde plano, con una unidad por pata.
  4. Barras 10–12: C == EL exactamente (97, 96, 95): no serían entradas nuevas; el corto se mantiene porque C no supera XU.
  5. Barra 14: el simétrico de la 8 (EU[14] = XU[14] = 99,00): sale del corto; la entrada larga llega en la 15 y se queda sin fill al final de la muestra. Equity final sin costes: 993,00 con posición 0.

Las cuentas de las barras clave

Primera decisión (t=4):
  EU[4] = max(high[0..3]) = max(101,00; 102,00; 101,50; 102,50) = 102,50
  EL[4] = min(low[0..3])  = min(99,00; 100,00; 99,50; 100,50)   = 99,00
  102,50 > 102,50 -> False                                      -> 0 (HOLD)
Entrada (t=5):
  EU[5] = max(high[1..4]) = max(102,00; 101,50; 102,50; 103,00) = 103,00
  104,00 > 103,00 -> True                                       -> +1
  con ventana inclusiva: max(high[2..5]) = 105,00 ; 104,00 > 105,00 -> False
Salida con entrada contraria (t=8):
  EL[8] = min(low[4..7]) = min(101,00; 102,00; 103,00; 101,00) = 101,00
  XL[8] = min(low[6..7]) = min(103,00; 101,00)                  = 101,00
  100,00 < 101,00 en ambos; estado +1 -> manda exit_long         -> 0
Caja:  1.000,00 − 104,50 = 895,50 (fill 1) ; 895,50 + 99,50 = 995,00 (fill 2)
       995,00 + 98,50 = 1.093,50 (fill 3) ; 1.093,50 − 100,50 = 993,00 (fill 4)
Equity: cierre 6 = 895,50 + 105,00 = 1.000,50 ; cierre 10 = 1.093,50 − 97,00 = 996,50
        final = 993,00 (posición 0; la señal de la barra 15 no se ejecuta)

Diagrama

Donchian/Turtle: canal de entrada desplazado, canal de salida y fills en la apertura siguiente

flowchart LR
    W(["WARMUP: t < entry"]) --> FLAT["Plano 0"]
    FLAT -->|"C > EU · BUY 1"| L["Largo +1"]
    FLAT -->|"C < EL · SELL 1"| S["Corto -1"]
    L -->|"C < XL · SELL 1"| FLAT
    S -->|"C > XU · BUY 1"| FLAT
    L -->|"C >= XL"| L
    S -->|"C <= XU"| S
    FLAT -->|"EL <= C <= EU"| FLAT

No hay flecha entre largo y corto: una ruptura contraria cierra primero (la salida manda) y la nueva entrada se decide en la barra siguiente.

Cómo lanzarla

Backtest con ambos motores sobre NVDA 1d, con los defaults:

BASE=http://localhost:8000
SID=$(curl -sS -X POST "$BASE/v1/backtest" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p11-0001",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": {
    "strategy": {"template_id": "P11", "params": {"entry": 20, "exit": 10},
                 "name": "Donchian 20/10 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": "P11", "params": {"entry": 20, "exit": 10},
                 "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"]
print(SID, r["parity"]["verdict"])
for res in r["results"]:
    print(res["adapter"], res["run_metrics"]["n_fills"])

Derivar una variante con un canal de entrada de 55 (el clásico "sistema 2" de las Tortugas):

curl -sS -X POST "$BASE/v1/strategies/$SID/derive" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p11-derive", "correlation_id": null, "schema_version": "v1",
  "payload": {"param_overrides": {"entry": 55}, "name": "Donchian 55/10",
              "slug": "donchian-55-10", "tags": ["sensibilidad"]}}' | jq '.payload.strategy_id, .payload.cache_forecast'

entry es parámetro de feature (tabla de invalidación, §5.3 de la spec): donchian{55} es fallo de caché si no se calculó antes y donchian{10} se reutiliza. La particularidad de P11 es que la clave de caché es la spec, no el rol: si ya calculaste donchian{20} como canal de entrada, un candidato con exit = 20 lo reutiliza como canal de salida. Ojo: derivar {"exit": 20} desde 20/10 daría exit == entry y la API respondería con PolicyParamError.

Sweep de sus variant_keys (rejilla completa del catálogo):

curl -sS -X POST "$BASE/v1/sweep" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p11-sweep", "correlation_id": null, "schema_version": "v1",
  "payload": {"base_strategy_id": "'"$SID"'",
    "grid": {"entry": [20, 40, 55, 80, 100], "exit": [5, 10, 20]},
    "candidate_policy": {"drop_invalid": true, "drop_duplicates": true,
                         "register_strategies": true, "name_template": "{template_name} {entry}/{exit}"},
    "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.dropped, .payload.factorization_plan'

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

Cuánto cuesta la rejilla

Concepto Cuenta (spec §5.4)
Candidatos cartesianos 5 entry × 3 exit = 15
Inválidos por exit < entry 1: (20, 20); se registra, no desaparece del denominador
Candidatos válidos 2 (con entry = 20) + 4 · 3 = 14
Canales distintos {20, 40, 55, 80, 100} ∪ {5, 10, 20} = 7 (el 20 se comparte entre roles)
Peticiones lógicas sin factorizar 14 × 2 = 28 → 21 aciertos de caché (75 % de ahorro)
Memoria de los 7 canales con N = 632.000 ≈ 79,6 MB (2 salidas f64 con máscara cada uno)

Qué mirar en los resultados

  • Número de fills por carril antes que cualquier tolerancia. La spec avisa (§11, punto 3) de que un canal inclusivo produce cero trades; una divergencia relativa entre dos series de ceros es cero, y un comparador que solo mire tolerancias no lo vería.
  • Paridad nativa: NT_ONLINE(native) falla ya en la capa features (barra 32 de KO 1d, 49,73 frente a 49,17) porque el DonchianChannel de Nautilus es inclusivo. Es otra definición, no un problema de redondeo.
  • Barras con entrada contraria y salida simultáneas (como la 8 y la 14 del fixture) y barras con C == EU o C == EL exacto: en activos de tick grueso explican entradas "que no se produjeron".
  • Anchura (EU − EL)/(XU − XL): cuantifica la asimetría del candidato.
  • Número mínimo de trades antes de ordenar por nada (spec §10.2): con entry = 100 en diario puede haber una decena.
  • fills = Σ|delta|: nunca invierte.
  • DATA_GAP_SESSIONS en KO: el canal cruza el hueco de 271 sesiones y suprime rupturas hasta entry barras.

Causalidad comprobada

El test de "futuros alterados" (spec §8) desplaza en bloque +15 todos los precios posteriores a la barra 400 de NVDA 1d (600 barras, 20/10) y exige canales, objetivos, motivos, fills con fill_index ≤ 400 y equity hasta el cierre 400 idénticos bit a bit. El test específico de P11 modifica solo high[t] y low[t] de una barra interior: EU[t], EL[t], XU[t], XL[t] no deben cambiar y EU[t+1 .. t+entry] sí. Falla de inmediato con una ventana inclusiva.

Para reanudar en modo continue basta guardar las últimas entry barras de high y low (1.600 bytes con entry = 100): el canal de salida es un sufijo de esa misma ventana. Guardar solo EU y EL no basta, porque el canal siguiente necesita saber qué barra sale de la ventana.

Errores típicos

Error Consecuencia Cómo evitarlo
exit >= entry PolicyParamError (la asimetría que define la receta desaparece) Respeta exit < entry; en el sweep cuenta como inválido
Incluir high[t] en el canal Cero trades, que parece "parámetros poco agresivos" Usa donchian (desplazado), no rolling_max
Llenar al precio del canal cuando high[t] > EU[t] Stop fantasma, sesgo optimista Señal solo con close[t] > EU[t]; fill en open[t+1]
Invertir de largo a corto en la misma barra No cuadra con el motor La salida manda; la entrada contraria, en la barra siguiente
Tratar el toque C == EU como ruptura Entradas de más Desigualdad estricta
Arrancar un tramo con menos de entry barras previas Canal estrecho y rupturas fabricadas Cargar los antecedentes que pide el warmup
Snapshot online sin las cinco claves durante el warmup KeyError en la política Entregar siempre close y los cuatro canales, con NaN si no hay valor

Variantes y extensiones

  • Sistema Turtle completo: unidades por volatilidad (N), piramidación y filtro del trade ganador; sería otra entrada de catálogo.
  • Canal inclusivo con ruptura sobre high: otra receta con otra señal.
  • Canal de la sesión: territorio de P12 ORB.
  • Salida por ATR: combinar con el trailing de P07 o el Chandelier de P19.
  • Parientes: P10 Ichimoku (puntos medios de rango con ventanas inclusivas) y P04 (bandas como canal).

Resumen

  • P11 entra al cierre que supera el máximo (o rompe el mínimo) de las entry barras anteriores y sale al romper el canal de exit barras en contra.
  • Las cuatro desigualdades son estrictas; el toque no cuenta.
  • La salida tiene prioridad y nunca hay reversión: |delta| siempre 1.
  • En el fixture: largo 104,50 → 99,50, corto 98,50 → 100,50, equity final 993,00 y una señal final sin fill.
  • 14 candidatos válidos y 7 canales; la variante nativa de Nautilus diverge por definición (ventana inclusiva).

Para practicar

  1. En el fixture, calcula a mano EU[15] y XU[14] y comprueba 101,00 y 99,00. ¿Por qué la barra 15 es una entrada y no una salida?
  2. Rehaz el fixture con una ventana inclusiva: calcula EU[5], EU[15] y comprueba que no hay ninguna entrada larga en toda la serie.
  3. Lanza el sweep 5×3 sobre NVDA 1d y KO 1d y comprueba en la respuesta 15 solicitados, 14 efectivos y 1 inválido. ¿Qué candidato gana y con cuántos trades?