Saltar a contenido

P01 · Cruce de medias EMA

Campo Valor
Familia Tendencia · dirección continua (siempre largo o corto tras el warmup)
Features requeridas ema_fast, ema_slow (primitiva ema, entrada close)
Parámetros y defaults fast = 20, slow = 50
Rejilla del catálogo fast ∈ {5, 10, 20, 40}, slow ∈ {50, 100, 150, 200} → 16 candidatos válidos, 8 curvas EMA distintas
variant_keys fast, slow
Restricciones enteros ≥ 1 y fast < slow (si no, PolicyParamError)
first_decision_index slow − 1 = 49 con defaults
Activos y timeframes NVDA y KO en los 7 TF (1min1d); AMZN, INTC, MSFT, TSLA en 1d; BME y FX en 1d con reservas
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): la EMA nativa de Nautilus usa semilla "primer valor" (nt_seed_first_value) y la canónica semilla SMA; primera divergencia en la barra 19 de KO 1d (ema_fast 45,3790 frente a 45,3564). Es una variante declarada, no un bug

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

Qué vas a aprender

  • Por qué dos medias exponenciales de distinta velocidad sirven para seguir una tendencia.
  • Que P01, pese a su nombre, no reacciona a un "cruce" sino al nivel relativo de las medias en cada barra.
  • A seguir un trade completo del fixture de la especificación: entrada, reversión de largo a corto y la cuenta de caja.
  • Por qué una rejilla 4×4 cuesta 8 curvas y no 32.
  • Qué significa la divergencia "nativa" de la matriz de paridad y por qué no invalida la estrategia.

La idea en una frase

Si la media rápida está por encima de la lenta, estar largo; si está por debajo, estar corto.

Intuición de mercado

Una EMA es un "promedio con memoria que se desvanece": pesa más lo reciente. La rápida (20) reacciona a las últimas semanas; la lenta (50) resume un periodo más largo. Cuando el precio sube de forma sostenida, la rápida se despega por encima de la lenta; cuando cae, por debajo. Comparar las dos es una forma barata de preguntar "¿el pasado reciente es mejor que el pasado largo?".

Analogía. Dos termómetros en una habitación: uno reacciona en minutos, el otro en horas. Si el rápido marca más que el lento, la habitación se está calentando. No sabes cuánto durará, pero sabes la dirección.

Por qué podría funcionar. Los mercados muestran momentum de medio plazo: las tendencias persisten más de lo que predeciría un paseo aleatorio, por flujos lentos de capital, difusión gradual de información y comportamiento gregario.

Cuándo falla.

  • Mercados laterales: las medias se entrecruzan una y otra vez y cada cruce es una reversión con coste (el famoso whipsaw).
  • Giros bruscos: la media lenta tarda en reaccionar; se devuelve buena parte de la tendencia antes de invertir.
  • Costes: cada reversión mueve 2 unidades (de +1 a −1), así que paga doble comisión.

Sesgos típicos al evaluarla.

  • Sobreajuste de la pareja (elegir 17/43 porque "salió mejor"): compara siempre contra la rejilla completa.
  • Sesgo de supervivencia: probarla solo en NVDA (una tendencia histórica excepcional) hace que parezca mejor de lo que es.
  • Warmup distinto entre candidatos: slow = 200 pierde 199 barras; compara candidatos desde un inicio común.

Las reglas exactas

La regla canónica del catálogo: "Con ambas medias válidas, objetivo +1 si EMA(f)>EMA(s), −1 si EMA(f)<EMA(s); igualdad conserva el objetivo anterior. Estado inicial 0. El objetivo solo genera una orden cuando cambia. Ejecución en la apertura siguiente."

Situación en el cierre de t Objetivo target[t] Motivo (reason)
t < first_decision_index (alguna EMA aún NaN) 0 WARMUP
F[t] > S[t] (estricto) +1 ENTRY / REVERSAL / HOLD
F[t] < S[t] (estricto) −1 ENTRY / REVERSAL / HOLD
F[t] == S[t] (igualdad exacta en f64) el anterior HOLD
F o S es NaN tardío el anterior HOLD
barra no operable (is_tradable = False) se congela el publicado NOT_TRADABLE
  • Entrada: la primera barra válida en la que las medias están separadas ya da ±1 (no espera a un cruce).
  • Salida: no hay salida a plano; la "salida" de un largo es la reversión a corto.
  • Reversión: permitida; delta de 2 unidades.
  • Filtros: ninguno.
# decisión en el cierre de t, fill en el open de t+1
target_prev = 0
para cada barra t:
    si t < first_decision_index:             target = 0            # WARMUP
    si no, si F[t] > S[t]:                   target = +1
    si no, si F[t] < S[t]:                   target = -1
    si no:                                   target = target_prev  # empate o NaN
    si target != target_prev:
        delta = target - target_prev         # ±1 al abrir, ±2 al invertir
        orden(delta)  -> fill a open[t+1]    # nunca a close[t]
    target_prev = target

El nombre engaña: no es una regla de cruce

La regla compara niveles de la misma barra t; no consulta t−1 para detectar un cruce. Consecuencia: en la primera barra válida, si ema20 > ema50, P01 ya entra largo sin haber visto ningún cruce. La especificación lo registra en su §11 como desviación de forma respecto a la lectura ingenua del nombre.

Los indicadores que usa

Feature Fórmula first_valid
ema{n} α = 2/(n+1); semilla ema[n−1] = SMA(close[0..n−1]); después ema[t] = α·close[t] + (1−α)·ema[t−1] n − 1

first_decision_index = max(fast−1, slow−1) = slow − 1. Con defaults son 49 barras de warmup; con slow = 200, 199.

La EMA es una recursión causal: ema[t] solo depende de close[0..t]. Detalle numérico y comparación entre motores en Indicadores paso a paso y en Indicadores y features.

Ejemplo barra a barra

Fixture §6 de docs/estrategias/P01.md: fast = 3, slow = 5 (fuera de rejilla a propósito, para calcular a mano), cash_inicial = 1.000,00, tick 0,01, sin costes. α_3 = 0,5, α_5 = 1/3. first_valid(ema3) = 2, first_valid(ema5) = 4first_decision_index = 4.

Las barras 0–1 son idénticas a la 2 (O=100,00, H=100,20, L=99,80, C=100,00) con ambas EMA en NaN.

t O H L C ema3 ema5 target orden fill pos. PnL acum. sin costes equity con 5 bps*
2 100,00 100,20 99,80 100,00 100,000000 NaN 0 WARMUP 0 0,00 1.000,00
3 100,00 100,20 99,80 100,00 100,000000 NaN 0 WARMUP 0 0,00 1.000,00
4 100,00 100,20 99,80 100,00 100,000000 100,000000 0 HOLD (empate) 0 0,00 1.000,00
5 100,00 100,20 99,80 100,00 100,000000 100,000000 0 HOLD 0 0,00 1.000,00
6 100,00 100,20 99,80 100,00 100,000000 100,000000 0 HOLD 0 0,00 1.000,00
7 100,00 100,30 99,70 100,00 100,000000 100,000000 0 HOLD 0 0,00 1.000,00
8 100,50 102,30 100,30 102,00 101,000000 100,666667 +1 ENTRY BUY 1 0 0,00 1.000,00
9 102,50 104,30 102,30 104,00 102,500000 101,777778 +1 102,50 +1 1,50 1.001,45
10 104,50 106,30 104,30 106,00 104,250000 103,185185 +1 +1 3,50 1.003,45
11 106,00 106,40 104,60 105,00 104,625000 103,790123 +1 +1 2,50 1.002,45
12 104,50 105,10 102,60 103,00 103,812500 103,526749 +1 +1 0,50 1.000,45
13 102,50 103,10 100,60 101,00 102,406250 102,684499 −1 REVERSAL SELL 2 +1 −1,50 998,45
14 100,50 101,10 98,60 99,00 100,703125 101,456333 −1 100,50 −1 −0,50 999,35
15 98,50 99,10 96,60 97,00 98,851562 99,970889 −1 −1 1,50 1.001,35

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 → 102,50 · 0,0005 = 0,05125 → 0,05; fill 2 → 2 · 100,50 · 0,0005 = 0,1005 → 0,10. El PnL acumulado es equity − 1.000,00 del fixture.

Lo que hay que ver en la tabla:

  1. Barras 4–7: ambas EMA valen exactamente 100,000000; la igualdad conserva el estado inicial 0 y no se emite orden. Es el caso "empate determinista" de la especificación.
  2. Barra 8: 101,000000 > 100,666667 → objetivo +1. La orden se decide al cierre de 8 y se llena al open de 9 (102,50), no al cierre de 8 (102,00).
  3. Barra 13: 102,406250 < 102,684499 → reversión. El fill en el open de 14 vende 2 unidades: 1 para cerrar el largo y 1 para abrir el corto. Caja: 897,50 + 2·100,50 = 1.098,50.
  4. Barra 15: el corto queda abierto; la equity final es 1.098,50 − 97,00 = 1.001,50. No hay liquidación automática al final.

Las cuentas de las barras clave

Semillas (t=2 y t=4):  ema3[2] = (100+100+100)/3 = 100,000000
                       ema5[4] = (100·5)/5       = 100,000000
Entrada (t=8):         ema3[8] = 0,5·102,00 + 0,5·100,000000     = 101,000000
                       ema5[8] = (1/3)·102,00 + (2/3)·100,000000 = 100,666667   -> F > S -> +1
Reversión (t=13):      ema3[13] = 0,5·101,00 + 0,5·103,812500     = 102,406250
                       ema5[13] = (1/3)·101,00 + (2/3)·103,526749 = 102,684499  -> F < S -> -1
Caja:                  1.000,00 - 1·102,50 = 897,50   (fill 1, open[9])
                       897,50 + 2·100,50   = 1.098,50 (fill 2, open[14])
Equity final:          1.098,50 + (-1)·97,00 = 1.001,50

Diagrama

Cruce de EMA: medias, zonas largo/corto y fills en la apertura siguiente

flowchart LR
    W(["WARMUP: t < slow-1"]) -->|"ambas EMA válidas y F == S"| FLAT["Plano 0"]
    W -->|"F > S"| L["Largo +1"]
    W -->|"F < S"| S["Corto -1"]
    FLAT -->|"F > S · BUY 1"| L
    FLAT -->|"F < S · SELL 1"| S
    L -->|"F < S · SELL 2"| S
    S -->|"F > S · BUY 2"| L
    L -->|"F >= S"| L
    S -->|"F <= S"| S

El estado plano solo existe al principio (arrastrado por el empate): una vez en mercado, P01 alterna entre largo y corto.

Cómo lanzarla

Backtest con ambos motores sobre NVDA 5min en 2024, con los defaults (petición real examples/api/01_backtest_p01_both.request.json; aquí con la estrategia referida por plantilla y parámetros, forma "inline" de la API de backtesting):

BASE=http://localhost:8000
curl -sS -X POST "$BASE/v1/backtest" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p01-0001",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": {
    "strategy": {"template_id": "P01", "params": {"fast": 20, "slow": 50},
                 "name": "EMA cross 20/50 base", "register": true},
    "data": {"asset_id": "NVDA", "timeframe": "5min",
             "window": {"start": "2024-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", "indicator_provider": "native"}}
  }}' | jq '.payload.signature.strategy_id, .payload.parity.verdict'
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": "P01", "params": {"fast": 20, "slow": 50}},
    "data": {"asset_id": "NVDA", "timeframe": "5min",
             "window": {"start": "2024-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"]          # P01-dc9260 con estos 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"])

Derivar una variante (20/100) desde la instancia base P01-dc9260 (petición real 06b_derive_p01_20_100.request.json):

curl -sS -X POST "$BASE/v1/strategies/P01-dc9260/derive" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p01-derive", "correlation_id": null, "schema_version": "v1",
  "payload": {"param_overrides": {"slow": 100}, "name": "EMA cross 20/100",
              "slug": "ema-cross-20-100", "tags": ["sensibilidad"]}}' | jq '.payload.strategy_id, .payload.cache_forecast'

slow es parámetro de feature: la respuesta anuncia ema:100 como fallo de caché esperado y ema:20 como reutilizable.

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

curl -sS -X POST "$BASE/v1/sweep" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p01-sweep", "correlation_id": null, "schema_version": "v1",
  "payload": {"base_strategy_id": "P01-dc9260",
    "grid": {"fast": [5, 10, 20, 40], "slow": [50, 100, 150, 200]},
    "candidate_policy": {"drop_invalid": true, "drop_duplicates": true,
                         "register_strategies": true, "name_template": "{template_name} {fast}/{slow}"},
    "data": {"asset_id": "NVDA", "timeframe": "5min",
             "window": {"start": "2024-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'

La respuesta real de ese sweep declara 16 puntos solicitados, 16 válidos y distintos (0 inválidos, 0 duplicados) y 8 curvas de feature distintas. El resultado se consulta en GET /v1/jobs/{job_id}.

Cuánto cuesta la rejilla

Concepto Cuenta
Candidatos cartesianos 4 fast × 4 slow = 16
Inválidos por fast < slow 0 (todo fast ≤ 40 < todo slow ≥ 50)
Curvas EMA distintas {5,10,20,40} ∪ {50,100,150,200} = 8
Llamadas lógicas sin factorizar 16 × 2 = 32 (ahorro del 75 %)
Políticas y ledgers 16 y 16: el ledger no se comparte, cada pareja es otra serie de objetivos

Los dos parámetros de P01 son de feature: cambiar cualquiera recalcula una EMA. Es lo contrario de P02, cuyos umbrales solo tocan la política. Un cambio de fee_rate o de initial_cash, en cambio, reutiliza las dos EMA y solo rehace el ledger.

Qué mirar en los resultados

  • parity.verdict antes que cualquier cifra. Con NT_FEATURES la respuesta real del ejemplo 01 da PASS en features (tolerancia rtol 1e-10), señales (exactas), fills (exactos) y equity (céntimos exactos).
  • Número de fills frente a |delta| total: en P01 las reversiones cuentan 2 unidades, así que la suma de |delta| supera al número de fills. En el ejemplo real sobre NVDA 5min 2024 hubo 328 fills.
  • costs_total frente a la ganancia bruta: en intradía, con cientos de reversiones, 5 bps pueden comerse todo el margen.
  • first_decision_index efectivo (49 con defaults) y el aviso DATA_GAP_SESSIONS si usas KO (271 sesiones ausentes; la EMA sigue a través del hueco).
  • Divergencia conocida: si pides NT_ONLINE con indicator_provider: "native", esperas PARITY_FAIL en la capa features en la barra fast−1 (semilla distinta). Converge con el tiempo, pero cerca del warmup puede cambiar el signo de F > S, que es exactamente lo que decide P01. Por eso no se esconde bajo una tolerancia.

Causalidad comprobada

La especificación define el test de "futuros alterados": sobre NVDA 1d (600 barras, 20/50) se multiplican por 1,35 todos los precios posteriores a la barra 400 y se exige que EMA, objetivos, motivos, fills con fill_index ≤ 400 y equity hasta el cierre 400 queden idénticos bit a bit. El fill de la señal de la barra 400 sí puede cambiar: su precio es open[401], que ya pertenece al tramo alterado. Confundir la frontera de la decisión con la del fill es justo el error que el test distingue.

Para reanudar P01 en modo continue basta un checkpoint con los dos escalares ema_fast[t0−1] y ema_slow[t0−1], el objetivo vigente y el estado del ledger; guardar solo la equity no basta.

Errores típicos

Error Consecuencia Cómo evitarlo
fast >= slow PolicyParamError (con fast == slow el objetivo quedaría en 0 para siempre) La API lo rechaza; en un sweep se cuenta como inválido, no desaparece
Esperar un "cruce" para entrar Tu backtest manual no cuadra con el motor Recuerda: regla de nivel, entra en la primera barra válida
Llenar al close[t] Sesgo optimista (usas un precio que ya conocías) El ledger siempre llena en open[t+1]
Sustituir EMA por SMA "porque da igual" Otra estrategia: señales distintas La SMA sería P01-SMA, otra entrada de catálogo
Comparar 20/50 con 40/200 desde barras distintas Muestras distintas Fija un inicio común ≥ 199
Olvidar el empate Esperar que F == S ponga plano La igualdad conserva el objetivo anterior

Variantes y extensiones

  • P01-SMA: misma lógica con medias simples; exige su propia entrada de catálogo (no es una sustitución silenciosa).
  • Banda neutra: estar plano si |F − S| < ε·S; reduce whipsaw, pero es otra receta.
  • Solo largos: target_lots = [0, 1] con la regla −1 → 0; habitual en acciones.
  • Filtro de régimen: combinarla con ADX (P15) para operar solo con tendencia fuerte.
  • Parientes: P03 MACD (diferencia de EMA suavizada) y P18 Hull (media menos retardada).

Resumen

  • P01 compara EMA(fast) y EMA(slow) en cada cierre: +1 si la rápida está por encima, −1 si está por debajo, empate conserva.
  • Es un sistema siempre en mercado tras el warmup; las reversiones mueven 2 unidades.
  • Decisión en el cierre de t, fill en el open de t+1; el fixture lo muestra con una entrada en 102,50 y una reversión en 100,50.
  • Una rejilla 4×4 son 16 candidatos pero solo 8 curvas EMA: los periodos se reutilizan.
  • Paridad PASS en todos los carriles salvo la EMA nativa de Nautilus, que es una variante declarada por la semilla.

Para practicar

  1. Con el fixture, calcula a mano ema3[9] y ema5[9] a partir de los valores de la barra 8 y comprueba 102,500000 y 101,777778.
  2. Rehaz la columna "equity con 5 bps" con 10 bps. ¿Cuál es la equity final? (Pista: 0,10 y 0,20 de comisión.)
  3. Lanza el sweep 4×4 sobre KO 1d y NVDA 1d. ¿Qué pareja gana en cada activo? ¿Es la misma? Discute qué dice eso sobre el sobreajuste.