Saltar a contenido

P03 · MACD: cruce de señal

Campo Valor
Familia Tendencia · dirección continua (siempre largo o corto tras la primera señal)
Features requeridas macd (una primitiva con tres salidas macd, signal, hist; depende de ema{fast} y ema{slow} sobre close)
Parámetros y defaults fast = 12, slow = 26, signal = 9
Rejilla del catálogo fast ∈ {8, 12, 16}, slow ∈ {21, 26, 35}, signal ∈ {5, 9, 12} → 27 candidatos válidos; 6 EMA, 9 líneas MACD, 27 señales
variant_keys fast, slow, signal
Restricciones enteros ≥ 1, fast < slow y signal ≥ 2 (si no, PolicyParamError)
first_decision_index slow + signal − 2 = 33 con defaults (la cadena suma)
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). UNSUPPORTED en NT_ONLINE(native) (12/12): "nautilus_trader 1.231.0 no ofrece un indicador nativo para 'macd'". No es un fallo: esa ruta no existe para P03

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

Qué vas a aprender

  • Cómo se construye el MACD a partir de dos EMA y por qué la señal es una tercera EMA encadenada.
  • Por qué el calentamiento de una cadena suma (slow + signal − 2) en lugar de tomar el máximo.
  • El error clásico de rellenar con ceros los NaN del MACD antes de calcular la señal, y cómo lo detecta el fixture.
  • Que P03 decide por el signo del histograma, no del MACD: puede ponerse corto con el MACD aún positivo.
  • Cómo una rejilla de 27 candidatos se factoriza en 6 EMA y 9 líneas MACD.

La idea en una frase

Si el MACD está por encima de su propia media (histograma positivo), estar largo; si está por debajo, estar corto.

Intuición de mercado

El MACD es la distancia entre una EMA rápida y una lenta: mide cuánto se ha adelantado el precio reciente al de medio plazo, es decir, el momentum. La señal es una media de ese momentum. El histograma (MACD − señal) pregunta: "¿el momentum está acelerando o frenando?". P03 se pone largo cuando acelera y corto cuando frena.

Analogía. El velocímetro y la aceleración de un coche. P01 mira si el coche va hacia delante o hacia atrás (qué media está arriba); P03 mira el pie en el pedal: si el coche acelera, largo; si frena, corto, aunque todavía avance.

Por qué podría funcionar. Reacciona antes que un cruce de medias puro: detecta que una tendencia pierde fuerza antes de que las medias se crucen. En mercados con tendencias limpias, captura buena parte del movimiento con menos retraso que P01.

Cuándo falla.

  • Mercados laterales y ruidosos: el histograma oscila alrededor de cero y cada cambio de signo es una reversión de 2 unidades; en 1min es un generador de costes.
  • Tendencias largas con pausas: una simple ralentización pone corto en plena tendencia alcista (el fixture lo muestra en la barra 6).
  • Siempre en mercado: no hay estado plano tras la primera señal; toda la exposición es 100 %.

Sesgos típicos al evaluarla.

  • Elegir 12/26/9 "porque es el estándar" y compararlo con candidatos probados en otra muestra: compara la rejilla completa sobre la misma muestra.
  • Warmups distintos: slow = 35, signal = 12 pierde 45 barras; 21/5, solo 24. Fija un inicio común.
  • Usar ewm(adjust=True) de pandas para reproducirla fuera del banco: da otra semilla y otras señales al principio.

Las reglas exactas

La regla canónica del catálogo: "MACD=EMA(f)-EMA(s), señal=EMA(g) de MACD, histograma=MACD-señal. Objetivo +1 cuando histograma>0 y -1 cuando <0; cero conserva estado. Abrir solo tras la validez de toda la cadena." Y su nota de ingeniería: "La EMA de la señal comienza con los valores finitos de MACD; no sustituir NaN por cero."

Situación en el cierre de t Objetivo target[t] Motivo (reason)
t < slow + signal − 2 (cadena incompleta) 0 WARMUP
hist[t] > 0 (estricto) +1 ENTRY / REVERSAL / HOLD
hist[t] < 0 (estricto) −1 ENTRY / REVERSAL / HOLD
hist[t] == 0 exacto el anterior HOLD
hist[t] NaN tardío el anterior HOLD
barra no operable (is_tradable = False) se congela el publicado NOT_TRADABLE
  • Entrada: la primera barra con cadena válida y hist ≠ 0 ya da ±1.
  • Salida: no hay salida a plano; la salida de un largo es la reversión a corto (delta 2).
  • Reversión: permitida y habitual; todas las transiciones posteriores a la primera son reversiones.
  • Filtros: ninguno. No es una regla de cruce: se compara el nivel de hist[t] con 0, sin mirar t−1. Con hist nunca exactamente 0, nivel y cruce dan la misma serie de objetivos; solo difieren en el empate, donde manda "cero conserva".
# decisión en el cierre de t, fill en el open de t+1
state = 0
para cada barra t:
    si t < slow + signal - 2:   target = 0          # WARMUP: la señal aún no existe
    si no, si hist[t] > 0:      target = +1
    si no, si hist[t] < 0:      target = -1
    si no:                      target = state      # hist == 0 o NaN: conserva
    si target != state:
        orden(target - state)  -> fill a open[t+1]  # ±1 al abrir, ±2 al invertir
    state = target

El histograma, no el MACD

Confundir hist > 0 con macd > 0 da otra estrategia (el "cruce del cero" del MACD). En el fixture, en la barra 6 el MACD vale +0,0813 y el histograma −0,0192: P03 invierte a corto; la versión equivocada seguiría larga.

Los indicadores que usa

Feature Fórmula first_valid
ema{n} semilla SMA(close[0..n−1]) en n−1; α = 2/(n+1) n − 1
macd.macd ema{fast} − ema{slow} slow − 1 (25)
macd.signal EMA(signal) de macd, sembrada con la SMA de los primeros signal valores finitos de macd slow + signal − 2 (33)
macd.hist macd − signal slow + signal − 2 (33)

first_decision_index = slow + signal − 2: 33 con defaults, 45 en el peor caso de la rejilla (35, 12) y 24 en el mejor (21, 5). Las dos EMA son paralelas (misma entrada, se combinan en la misma barra → máximo), pero la señal es una recursión encadenada sobre su resultado → suma signal − 1 barras (regla chained_sum, R1/R2 del contrato extendido).

Rellenar con ceros los slow − 1 NaN del MACD haría la señal válida desde signal − 1 (8 con defaults) con un arranque sesgado a cero: decenas de señales falsas al principio. Detalle en Indicadores paso a paso, Las 23 primitivas de fase B e Indicadores y features.

Ejemplo barra a barra

Fixture §6 de docs/estrategias/P03.md: fast = 2, slow = 3, signal = 2 (fuera de rejilla a propósito), cash_inicial = 1.000,00, tick 0,01, sin costes. first_decision_index = 3 + 2 − 2 = 3. Features con 4 decimales half-even, como en la spec.

Barras 0–1: O=10,00/10,00, H=10,10/10,25, L=9,95/9,98, C=10,00/10,20; objetivo 0 WARMUP (en la 1, ema2 = 10,1000 y el resto NaN).

t O H L C ema2 ema3 macd signal hist target orden fill pos. PnL acum. sin costes* equity con 5 bps*
2 10,20 10,22 10,05 10,10 10,1000 10,1000 0,0000 NaN NaN 0 WARMUP 0 0,00 1.000,00
3 10,10 10,45 10,08 10,40 10,3000 10,2500 0,0500 0,0250 0,0250 +1 ENTRY BUY 1 0 0,00 1.000,00
4 10,40 10,95 10,38 10,90 10,7000 10,5750 0,1250 0,0917 0,0333 +1 10,40 +1 0,50 1.000,49
5 10,90 11,35 10,88 11,30 11,1000 10,9375 0,1625 0,1389 0,0236 +1 +1 0,90 1.000,89
6 11,30 11,32 11,05 11,10 11,1000 11,0188 0,0813 0,1005 −0,0192 −1 REVERSAL SELL 2 +1 0,70 1.000,69
7 11,10 11,12 10,65 10,70 10,8333 10,8594 −0,0260 0,0161 −0,0422 −1 11,10 −1 1,10 1.001,08
8 10,70 10,72 10,25 10,30 10,4778 10,5797 −0,1019 −0,0626 −0,0393 −1 −1 1,50 1.001,48
9 10,30 10,32 10,05 10,10 10,2259 10,3398 −0,1139 −0,0968 −0,0171 −1 −1 1,70 1.001,68
10 10,10 10,45 10,08 10,40 10,3420 10,3699 −0,0279 −0,0509 0,0230 +1 REVERSAL BUY 2 −1 1,40 1.001,38
11 10,40 10,95 10,38 10,90 10,7140 10,6350 0,0790 0,0357 0,0433 +1 10,40 +1 1,90 1.001,87
12 10,90 11,45 10,88 11,40 11,1713 11,0175 0,1539 0,1145 0,0394 +1 +1 2,40 1.002,37
13 11,40 11,42 11,15 11,20 11,1904 11,1087 0,0817 0,0926 −0,0109 −1 REVERSAL SELL 2 +1 2,20 1.002,17
14 11,20 11,22 10,75 10,80 10,9301 10,9544 −0,0242 0,0147 −0,0389 −1 11,20 −1 2,60 1.002,56
15 10,80 10,82 10,45 10,50 10,6434 10,7272 −0,0838 −0,0510 −0,0328 −1 −1 2,90 1.002,86

OHLC, features, objetivos, motivos y fills son literales del fixture. *El fixture solo da la equity en los cierres 4 (1.000,50) y 7 (1.001,10); la columna "PnL acumulado" completa es cálculo de este manual con la regla del ledger equity = cash + posición · close (caja: 989,60 tras el fill 1; 1.011,80 tras el 2; 991,00 tras el 3; 1.013,40 tras el 4), y coincide con el fixture en las dos barras que este da. "Equity con 5 bps" también es cálculo propio: hypothetical_5bps (fee_rate = 0,0005, sin spread ni deslizamiento), comisión = |delta| · precio · 0,0005 redondeada al céntimo half-even, restada en la barra del fill:

Fill Barra Delta · precio Cuenta Comisión
1 BUY 1 4 1 · 10,40 0,00520 0,01
2 SELL 2 7 2 · 11,10 0,01110 0,01
3 BUY 2 11 2 · 10,40 0,01040 0,01
4 SELL 2 14 2 · 11,20 0,01120 0,01

Lo que hay que ver en la tabla:

  1. Barra 2: el MACD ya es finito (0,0000) pero la señal no existe: sigue siendo WARMUP. "Abrir solo tras la validez de toda la cadena".
  2. Barra 3: primera decisión válida y ya es una entrada: hist = 0,0250 > 0 → +1. Fill al open de 4 (10,40).
  3. Barra 6: el MACD sigue positivo pero cae por debajo de su señal → reversión: SELL 2 en el open de 7.
  4. Barras 10 y 13: dos reversiones más. Cuatro fills, tres de ellos de 2 unidades: exposición del 100 % desde la barra 4.
  5. Barra 15: la posición corta queda abierta y se valora al cierre (10,50); no hay liquidación automática. La señal de la última barra no tendría fill.

Las cuentas de las barras clave

Semillas:     ema2[1] = (10,00 + 10,20)/2          = 10,1000   (α2 = 2/3)
              ema3[2] = (10,00 + 10,20 + 10,10)/3  = 10,1000   (α3 = 1/2)
t = 3:        ema2[3] = (2/3)·10,40 + (1/3)·10,1000 = 10,3000
              ema3[3] = 0,5·10,40 + 0,5·10,1000     = 10,2500
              macd[2] = 0,0000 ; macd[3] = 0,0500
              signal[3] = (macd[2] + macd[3])/2     = 0,0250   <- primeros 2 MACD FINITOS
              hist[3] = 0,0500 − 0,0250 = 0,0250 > 0 -> +1 ENTRY
t = 6:        ema2[6] = (2/3)·11,10 + (1/3)·11,1000 = 11,1000
              ema3[6] = 0,5·11,10 + 0,5·10,9375     = 11,01875
              macd[6] = 11,1000 − 11,01875          = 0,08125
              signal[6] = (2/3)·0,08125 + (1/3)·0,138889 = 0,100463
              hist[6] = 0,08125 − 0,100463 = −0,019213 < 0 -> −1 REVERSAL
Caja:         1.000,00 − 10,40       = 989,60   (fill 1, open[4]); equity cierre 4 = 989,60 + 10,90 = 1.000,50
              989,60 + 2·11,10       = 1.011,80 (fill 2, open[7]); equity cierre 7 = 1.011,80 − 10,70 = 1.001,10
              1.011,80 − 2·10,40     = 991,00   (fill 3, open[11])
              991,00 + 2·11,20       = 1.013,40 (fill 4, open[14]); equity final = 1.013,40 − 10,50 = 1.002,90

Si se hubieran rellenado macd[0] y macd[1] con ceros, la señal existiría ya en la barra 1 y la primera decisión llegaría dos barras antes: el fixture exige decision_index_valid[0..2] = False justamente para detectarlo.

Diagrama

flowchart LR
    W(["WARMUP: t < slow+signal-2"]) -->|"hist == 0"| FLAT["Plano 0"]
    W -->|"hist > 0 · BUY 1"| L["Largo +1"]
    W -->|"hist < 0 · SELL 1"| S["Corto -1"]
    FLAT -->|"hist > 0 · BUY 1"| L
    FLAT -->|"hist < 0 · SELL 1"| S
    L -->|"hist < 0 · SELL 2"| S
    S -->|"hist > 0 · BUY 2"| L
    L -->|"hist >= 0"| L
    S -->|"hist <= 0"| S

El plano solo existe al principio (serie constante o hist == 0 exacto); una vez en mercado, P03 alterna entre largo y corto como P01.

Cómo lanzarla

Backtest con ambos motores sobre NVDA 5min en 2024 con los defaults (forma "inline" de la API de backtesting, mismo payload que 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-p03-0001",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": {
    "strategy": {"template_id": "P03", "params": {"fast": 12, "slow": 26, "signal": 9},
                 "name": "MACD 12/26/9 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"}}
  }}' | 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": "P03",
                 "params": {"fast": 12, "slow": 26, "signal": 9}, "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",
    "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"]["final_equity"], res["run_metrics"]["n_fills"])

No pidas NT_ONLINE con indicator_provider: "native": Nautilus 1.231.0 no tiene MACD nativo y la matriz lo registra como UNSUPPORTED. NT_FEATURES funciona porque recibe la feature ya calculada (D-14).

Derivar una variante con una señal más lenta:

curl -sS -X POST "$BASE/v1/strategies/$SID/derive" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p03-derive", "correlation_id": null, "schema_version": "v1",
  "payload": {"param_overrides": {"signal": 12}, "name": "MACD 12/26/12",
              "slug": "macd-12-26-12", "tags": ["sensibilidad"]}}' \
  | jq '.payload.strategy_id, .payload.cache_forecast'

En P03 ningún parámetro es solo de política: los tres entran en la FeatureSpec de macd, así que cache_forecast debe anunciar un fallo de caché de la feature macd. Según la tabla de invalidación (§5.3 de la spec), un cambio de signal reutiliza íntegras las dos EMA y la línea MACD de la pareja (12, 26) y solo rehace la recursión de la señal, el histograma, la política y el ledger. Cambiar fast o slow rehace además una EMA.

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-p03-sweep", "correlation_id": null, "schema_version": "v1",
  "payload": {"base_strategy_id": "'"$SID"'",
    "grid": {"fast": [8, 12, 16], "slow": [21, 26, 35], "signal": [5, 9, 12]},
    "candidate_policy": {"drop_invalid": true, "drop_duplicates": true,
                         "register_strategies": true,
                         "name_template": "{template_name} {fast}/{slow}/{signal}"},
    "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'

Según §5.4 de la spec: 27 candidatos, 27 válidos. El resultado se consulta en GET /v1/jobs/{job_id}.

Cuánto cuesta la rejilla

Concepto Cuenta
Candidatos cartesianos 3 × 3 × 3 = 27
Inválidos por fast < slow 0 (máx. fast 16 < mín. slow 21); el validador la aplica igual
EMA distintas {8, 12, 16} ∪ {21, 26, 35} = 6
Líneas MACD distintas 3 × 3 = 9
Señales / histogramas 27 (9 MACD × 3 signal)
Pasadas recursivas 6 + 27 = 33 frente a 81 sin factorizar (ahorro del 59 %)

La nota del catálogo lo pide expresamente: "agrupar por pareja fast/slow y reutilizar su MACD entre longitudes signal". Si la caché informa de 27 cálculos de ema{12}, la factorización no está funcionando.

Qué mirar en los resultados

  • parity.verdict: PASS esperado en NT_FEATURES, NT_INTENT_REPLAY y NT_ONLINE(vectorta); señales comparadas con igualdad exacta, nunca con tolerancia.
  • Número de reversiones frente a entradas: en P03 todas las transiciones tras la primera son reversiones (delta 2). Un cero espurio delata un bug.
  • Exposición temporal: debe ser 100 % desde la primera barra con hist ≠ 0.
  • Turnover y costes: en 1min el histograma cruza el cero muy a menudo; el escenario de 5 bps es el que separa candidatos.
  • feature_build_ns por EMA, línea MACD y señal, para ver el ahorro de la factorización.
  • DATA_GAP_SESSIONS en KO: las tres recursiones atraviesan los 271 huecos sin pausa.

Causalidad comprobada

Test de "futuros alterados" de la spec: 200 barras sintéticas, (12, 26, 9), t0 = 120; se multiplican por 1,5 los cierres posteriores. Deben quedar idénticos bit a bit en [0, 120] las dos EMA, macd, signal, hist, objetivos, motivos y fills con fill_index ≤ 120. Tolerancia 0: la EMA es una recursión hacia delante, cualquier diferencia delata un desplazamiento mal puesto. Un segundo test perturba solo close[t0+1] y exige que signal[t0] no cambie: la señal, EMA de una EMA, es donde un shift con signo equivocado se esconde mejor.

Checkpoint mínimo para continue: ema_fast, ema_slow y signal en el corte (tres recursiones), macd por verificación, el objetivo vigente y la identidad (params_hash, formula_version). Guardar solo el objetivo obliga a un replay completo.

Errores típicos

Error Consecuencia Cómo evitarlo
fast >= slow PolicyParamError (con igualdad, macd ≡ 0; con fast > slow, signo invertido en silencio) En un sweep cuenta como inválido, no desaparece
signal = 1 PolicyParamError: la señal iguala al MACD y hist ≡ 0 Usa signal ≥ 2 (la rejilla empieza en 5)
Rellenar NaN del MACD con 0 Señal válida desde signal − 1; señales falsas al inicio La señal se siembra con los primeros MACD finitos
Operar el signo de macd en vez de hist Otra estrategia; gira más tarde Compara macd con su señal
hist >= 0 → +1 El empate se convierte en entrada larga Desigualdades estrictas; cero conserva
ewm(adjust=True) para replicarla Semilla distinta, señales distintas Recursión con semilla SMA
Pedir la ruta nativa de Nautilus UNSUPPORTED Usa NT_FEATURES o NT_ONLINE con streams de VectorTA

Variantes y extensiones

  • Cruce del cero del MACD (macd > 0 → largo): más lenta, equivale a un cruce de medias como P01.
  • Solo largos con target_lots = [0, 1]: el histograma negativo pone plano.
  • Banda muerta alrededor de 0 en el histograma para filtrar whipsaw; es otra receta.
  • Filtro de régimen con ADX (P15) o con volatilidad (P05).
  • Parientes: P01 cruce de EMA (la base del MACD) y P18 Hull (otra forma de reducir retraso).

Resumen

  • macd = EMA(fast) − EMA(slow), signal = EMA(g) del MACD, hist = macd − signal; +1 si hist > 0, −1 si < 0, cero conserva.
  • La señal se siembra con los primeros g MACD finitos: first_decision_index = slow + signal − 2 (33).
  • Sistema siempre en mercado tras la primera señal; las reversiones mueven 2 unidades.
  • 27 candidatos se factorizan en 6 EMA y 9 líneas MACD.
  • Paridad PASS en los tres carriles soportados; la ruta nativa de Nautilus no existe para MACD.

Para practicar

  1. Con el fixture, calcula a mano ema2[7], ema3[7], macd[7], signal[7] y hist[7] a partir de la barra 6. Deben salir 10,8333; 10,8594; −0,0260; 0,0161; −0,0422.
  2. Repite la barra 3 con el error: rellena macd[0] = macd[1] = 0 y calcula signal[1]. ¿En qué barra se tomaría la primera decisión y con qué objetivo?
  3. Lanza el sweep 3×3×3 y ordena por equity final con y sin costes (cost_scenario zero frente a hypothetical_5bps). ¿Cambia el candidato ganador? ¿Tienen los ganadores más o menos reversiones?