Saltar a contenido

P18 · Hull Suite: dirección de HMA

Campo Valor
Familia Tendencia · subfamilia pendiente de media: dirección continua (siempre largo o corto tras el warmup)
Features requeridas hma (primitiva nueva), que se apoya en dos wma sobre close: wma(floor(period/2)) y wma(period), más una tercera wma interna sobre la serie derivada
Parámetros y defaults period = 55, lag = 2
Rejilla del catálogo period ∈ {16, 32, 55, 89, 144}, lag ∈ {1, 2, 3} → 15 candidatos válidos, 5 curvas hma y 9 curvas wma distintas
variant_keys period, lag
Restricciones enteros; period ≥ 4 y lag ≥ 1 (si no, PolicyParamError); sin restricción cruzada
first_decision_index (period − 1) + (m − 1) + lag con m = round_half_up(sqrt(period)) = 54 + 6 + 2 = 62 con defaults (peor caso de la rejilla: 157)
Activos y timeframes NVDA y KO en los 7 TF (1min1d); AMZN, INTC, MSFT, TSLA en 1d; BME y FX tras el calendario de WP-01b. Solo usa close
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), pero en la capa fills, no en features: primera divergencia en la barra 63 de KO 1d, signal_index 62 frente a 56. Las HMA coinciden tras el warmup y las señales comparadas también; lo que cambia es cuándo empieza a operar cada lado (ver Qué mirar)

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

Qué vas a aprender

  • Cómo se construye la Hull Moving Average a partir de tres medias ponderadas y por qué adelanta a una media normal.
  • Por qué P18 no compara dos medias, sino una media consigo misma desplazada lag barras.
  • A contar un calentamiento encadenado con una parte paralela: máximo para las ramas, suma para la cadena.
  • Por qué 15 candidatos cuestan solo 5 curvas hma (y 9 wma, no 10).
  • Qué significa una divergencia de paridad en fills con señales idénticas.

La idea en una frase

Si la HMA de hoy está por encima de la de hace lag barras, estar largo; si está por debajo, estar corto.

Intuición de mercado

Una media móvil normal llega tarde: resume el pasado y, cuando el precio gira, sigue apuntando en la dirección vieja durante media ventana. Alan Hull propuso corregir ese retraso con un truco aritmético: 2·WMA(n/2) − WMA(n). La media corta va "por delante" de la larga; restar la larga al doble de la corta extrapola esa ventaja, y una última WMA corta (de longitud ≈ √n) suaviza el resultado. Queda una curva casi tan suave como una media de n barras, pero que gira mucho antes.

P18 solo mira la pendiente de esa curva: ¿sube o baja respecto a hace lag barras?

Analogía. Un coche con retrovisor y con navegador. La media normal es el retrovisor: te dice por dónde has pasado. La HMA es el navegador que estima hacia dónde vas con tu velocidad actual. P18 no pregunta dónde estás, solo si la flecha del navegador apunta hacia arriba o hacia abajo.

Por qué podría funcionar. Como P01, explota el momentum de medio plazo; al reaccionar antes que una SMA o una EMA del mismo periodo, captura una parte mayor de cada tendencia.

Cuándo falla.

  • Mercados laterales: una curva rápida también cambia de pendiente rápido. Cada cambio es una reversión con delta 2.
  • Sobrerreacción: la extrapolación 2·corta − larga exagera los picos; tras un salto aislado la HMA "se pasa" y luego corrige, generando una reversión espuria.
  • lag = 1 con period corto: la señal es casi ruido; cada pequeña ondulación de la curva invierte la posición.

Sesgos típicos al evaluarla.

  • Warmup desigual: period = 144, lag = 3 pierde 157 barras; period = 16, lag = 1, solo 19. Sobre Yahoo diario (~2.900 barras) el peor caso descarta el 5,4 % de la muestra. Compara todos los candidatos desde un comienzo común ≥ 157.
  • Implementaciones "equivalentes" que no lo son: round(n/2) en vez de floor(n/2), o int(sqrt(n)) en vez de redondear, cambian la curva. La receta lo fija expresamente.
  • Confundirla con EHMA/THMA: el catálogo excluye las variantes exponencial y triple de la suite.

Las reglas exactas

La regla canónica del catálogo: "HMA(n)=WMA(2·WMA(C,floor(n/2))−WMA(C,n),round_half_up(sqrt(n))). Objetivo +1 si HMA[t]>HMA[t−lag], −1 si menor; igualdad conserva. Largo/corto en apertura posterior."

Y sus engineering_notes: "Fijar truncamiento de n/2 y redondeo de raíz para evitar diferencias entre lenguajes. Pesos WMA ascendentes de 1 a n, máximo peso al dato reciente."

Situación en el cierre de t Objetivo target[t] Motivo (reason)
t < first_decision_index (hma[t] o hma[t−lag] no existe) 0 WARMUP
hma[t] > hma[t−lag] (estricto) +1 ENTRY / REVERSAL / HOLD
hma[t] < hma[t−lag] (estricto) −1 ENTRY / REVERSAL / HOLD
hma[t] == hma[t−lag] (igualdad exacta en f64) el anterior HOLD
NaN tardío en hma[t] o hma[t−lag] el anterior HOLD
barra no operable (is_tradable = False) se congela el publicado NOT_TRADABLE
  • Entrada: la primera barra de decisión ya da ±1; no espera a ningún cruce.
  • Salida: no hay salida a plano; la salida de un largo es la reversión a corto.
  • Reversión: permitida y directa, delta 2. En P18 todos los fills salvo el primero son de 2 unidades.
  • Filtros: ninguno. lag no es un filtro sino el desplazamiento de la comparación, y vive en la política, no en la feature.
# 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 hma[t] > hma[t - lag]:          target = +1
    si no, si hma[t] < hma[t - lag]:          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

Comparar con el pasado, nunca con el futuro

El desplazamiento correcto es lagged[lag:] = hma[:-lag] (la barra t ve hma[t−lag]). Escribir lagged[:-lag] = hma[lag:] compara con hma[t+lag] y convierte la estrategia en un oráculo perfecto. Es la fuga número 2 que la especificación enumera en su §8.1.

Los indicadores que usa

Feature Fórmula first_valid
wma{n} Σ_{i=0}^{n−1} close[t−n+1+i] · (i+1) / (n·(n+1)/2): el cierre más antiguo pesa 1, el más reciente pesa n n − 1
hma{period} half = floor(period/2); m = round_half_up(sqrt(period)); raw = 2·wma(half) − wma(period); hma = wma(raw, m) (period − 1) + (m − 1)

Los dos redondeos, tabulados para la rejilla:

period half = floor(p/2) sqrt(p) m first_valid(hma) first_decision_index con lag 1 / 2 / 3
16 8 4,000000 4 18 19 / 20 / 21
32 16 5,656854 6 36 37 / 38 / 39
55 27 7,416198 7 60 61 / 62 / 63
89 44 9,433981 9 96 97 / 98 / 99
144 72 12,000000 12 154 155 / 156 / 157

Tres cosas que conviene entender del calentamiento:

  1. Las dos WMA sobre close son ramas paralelas: raw vale desde el máximo de sus first_valid, es decir period − 1, no desde la suma.
  2. La WMA sobre raw encadena: añade m − 1 barras encima.
  3. lag suma aparte, porque hma[t−lag] no existe antes de first_valid(hma) + lag.

Con defaults: max(26, 54) + 6 + 2 = 62. Un cálculo ingenuo (period − 1 = 54) emitiría señales 8 barras antes de tiempo.

period = 32 es el caso discriminante del redondeo: int(sqrt(32)) = 5 pero m = 6. Y period = 55 lo es del truncamiento: round(55/2) = 28 pero half = 27. Curiosidad aritmética de la especificación: sqrt(period) nunca acaba exactamente en ,5 con period entero, así que round_half_up y el round() de Python coinciden siempre aquí; aun así el código debe decir round_half_up.

Detalle de ambas primitivas en Las 23 primitivas de fase B; la idea general de feature frente a política, en Indicadores y features.

Ventanas finitas, no recursiones

A diferencia de la EMA de P01 o P16, las tres WMA son ventanas finitas: un cierre malo en j contamina exactamente las salidas j … j + period + m − 2 y luego desaparece por completo. La especificación lo convierte en test propio (test_p18_finite_window_contamination): con period = 55, m = 7, perturbar close[350] no cambia hma[349] ni hma[411] en adelante.

Ejemplo barra a barra

Fixture §6 de docs/estrategias/P18.md: period = 4 (fuera de rejilla a propósito, emite PolicyParamWarning) y lag = 2 (dentro de la rejilla). half = floor(4/2) = 2, m = round_half_up(sqrt(4)) = 2. first_valid(wma2) = 1, first_valid(wma4) = first_valid(raw) = 3, first_valid(hma) = 3 + 1 = 4first_decision_index = 4 + 2 = 6. cash_inicial = 1.000,00, tick 0,01, sin costes. El fixture construye open[t] = close[t−1].

Las barras 0–2 (cierres 10,00 / 10,20 / 10,10) solo tienen wma2 (10,1333 en t=1 y t=2); raw y hma son NaN.

t O H L C raw hma hma[t−2] target orden fill pos. PnL acum. sin costes equity con 5 bps*
3 10,10 10,55 10,05 10,40 10,3700 NaN NaN 0 WARMUP 0 0,00 1.000,00
4 10,40 10,85 10,35 10,70 10,7600 10,6300 NaN 0 WARMUP 0 0,00 1.000,00
5 10,70 11,05 10,65 10,90 11,0067 10,9244 NaN 0 WARMUP 0 0,00 1.000,00
6 10,90 10,95 10,65 10,80 10,8967 10,9333 10,6300 +1 ENTRY BUY 1 0 0,00 1.000,00
7 10,80 10,85 10,40 10,50 10,5100 10,6389 10,9244 −1 REVERSAL SELL 2 10,80 +1 −0,30 999,69
8 10,50 10,55 10,10 10,20 10,1200 10,2500 10,9333 −1 HOLD 10,50 −1 0,00 999,98
9 10,20 10,25 9,80 9,90 9,8000 9,9067 10,6389 −1 HOLD −1 0,30 1.000,28
10 9,90 10,00 9,60 9,70 9,5933 9,6622 10,2500 −1 HOLD −1 0,50 1.000,48
11 9,70 9,80 9,50 9,60 9,5167 9,5422 9,9067 −1 HOLD −1 0,60 1.000,58
12 9,60 10,00 9,55 9,85 9,7833 9,6944 9,6622 +1 REVERSAL BUY 2 −1 0,35 1.000,33
13 9,85 10,30 9,80 10,15 10,1950 10,0578 9,5422 +1 HOLD 9,85 +1 0,65 1.000,62
14 10,15 10,60 10,10 10,45 10,5450 10,4283 9,6944 +1 HOLD +1 0,95 1.000,92
15 10,45 10,85 10,35 10,70 10,8033 10,7172 10,0578 +1 HOLD +1 1,20 1.001,17

Todo es literal del fixture (§6.1 OHLC, §6.2 features y objetivos, §6.4 fills y serie de equity) 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 (t=7): 1 · 10,80 · 0,0005 = 0,0054 → 0,01;
  • fill 2 (t=8): 2 · 10,50 · 0,0005 = 0,0105 → 0,01;
  • fill 3 (t=13): 2 · 9,85 · 0,0005 = 0,00985 → 0,01.

Comisiones acumuladas: 0,01 desde t=7, 0,02 desde t=8, 0,03 desde t=13. El PnL acumulado es equity − 1.000,00 del fixture.

Lo que hay que ver en la tabla:

  1. Warmup escalonado (t = 0…5): wma2 arranca en t=1, wma4 y raw en t=3, hma en t=4, y la decisión en t=6 por el lag. Seis barras de calentamiento para period = 4; period − 1 = 3 lo subestimaría en tres.
  2. Barra 6: 10,9333 > 10,6300 (contra hma[4], dos barras atrás) → +1. La orden se llena al open de 7 (10,80).
  3. Barra 7: 10,6389 < 10,9244 → reversión. El largo ha durado una sola barra y el fill del open de 8 vende 2 unidades.
  4. Barra 12: 9,6944 > 9,6622 → reversión a largo por un margen estrecho; compra 2 a 9,85 en el open de 13.
  5. Barra 15: la señal +1 coincide con la posición vigente: no hay delta ni fill (no es el caso "última señal sin fill", sino "sin cambio"). El largo queda abierto; no hay liquidación automática.

Este fixture no distingue open[t+1] de close[t]

Como open[t] = close[t−1] por construcción, llenar al cierre de la señal daría los mismos precios. La especificación lo advierte: el test de next-open de P18 usa otro fixture (cierre 100 → apertura 110). Aquí el orden temporal se ve en la columna fill, que siempre cae una fila por debajo de la orden.

Las cuentas de las barras clave

WMA con pesos ascendentes (máximo peso al dato reciente):
  wma2[t] = (1·C[t-1] + 2·C[t]) / 3
  wma4[t] = (1·C[t-3] + 2·C[t-2] + 3·C[t-1] + 4·C[t]) / 10

Primera HMA (t=4):
  wma2[3] = (10,10 + 2·10,40)/3 = 30,90/3 = 10,300000
  wma4[3] = (10,00 + 20,40 + 30,30 + 41,60)/10 = 102,30/10 = 10,230000
  raw[3]  = 2·10,300000 - 10,230000 = 10,370000
  wma2[4] = (10,40 + 2·10,70)/3 = 31,80/3 = 10,600000
  wma4[4] = (10,20 + 20,20 + 31,20 + 42,80)/10 = 104,40/10 = 10,440000
  raw[4]  = 2·10,600000 - 10,440000 = 10,760000
  hma[4]  = (1·10,370000 + 2·10,760000)/3 = 31,890000/3 = 10,630000

Comprobación de pesos: con pesos DESCENDENTES wma4[3] sería
  (4·10,00 + 3·10,20 + 2·10,10 + 1·10,40)/10 = 10,120000  (otra curva)

Entrada (t=6):
  raw[5] = 2·10,833333 - 10,660000 = 11,006667
  raw[6] = 2·10,833333 - 10,770000 = 10,896667
  hma[5] = (10,760000 + 2·11,006667)/3 = 10,924444
  hma[6] = (11,006667 + 2·10,896667)/3 = 32,800000/3 = 10,933333
  10,933333 > hma[4] = 10,630000  -> +1 ENTRY

Reversión (t=7):
  raw[7] = 2·10,600000 - 10,690000 = 10,510000
  hma[7] = (10,896667 + 2·10,510000)/3 = 10,638889
  10,638889 < hma[5] = 10,924444  -> -1 REVERSAL (delta 2)

Caja (sin costes):
  1.000,00 - 1·10,80 = 989,20      (fill 1, open[7])
  989,20  + 2·10,50  = 1.010,20    (fill 2, open[8])
  1.010,20 - 2·9,85  = 990,50      (fill 3, open[13])
Equity final: 990,50 + 1·10,70 = 1.001,20   (+1 abierto)
Trades: largo 10,80 -> 10,50 = -0,30; corto 10,50 -> 9,85 = +0,65;
        largo abierto 9,85 -> 10,70 = +0,85 no realizado

Fíjate en t=6: hma[6] > hma[5] por muy poco (10,9333 frente a 10,9244), pero la regla no mira la barra anterior sino hma[4]. Con lag = 3 la comparación sería contra hma[3], que es NaN, y la primera decisión se iría a t=7.

Diagrama

flowchart LR
    W(["WARMUP: t < first_valid(hma) + lag"]) -->|"hma[t] == hma[t-lag]"| FLAT["Plano 0"]
    W -->|"hma[t] > hma[t-lag] · BUY 1"| L["Largo +1"]
    W -->|"hma[t] < hma[t-lag] · SELL 1"| S["Corto -1"]
    FLAT -->|"sube · BUY 1"| L
    FLAT -->|"baja · SELL 1"| S
    L -->|"hma[t] < hma[t-lag] · SELL 2"| S
    S -->|"hma[t] > hma[t-lag] · BUY 2"| L
    L -->|"hma[t] >= hma[t-lag]"| L
    S -->|"hma[t] <= hma[t-lag]"| S

El plano solo existe al principio y solo si hay empate exacto, que en la práctica exige una serie de cierres constantes (test test_p18_flat_series_holds_zero: HMA constante, objetivo 0 para siempre, ningún fill). Todos los fills de la órbita largo↔corto son de 2 unidades.

Cómo lanzarla

Backtest con ambos motores sobre KO 1d con los defaults (forma "inline" de la estrategia en la API de backtesting; el cuerpo sigue a examples/api/01_backtest_p01_both.request.json). En diario, 62 barras de warmup son unos tres meses:

BASE=http://localhost:8000
SID=$(curl -sS -X POST "$BASE/v1/backtest" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p18-0001",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": {
    "strategy": {"template_id": "P18", "params": {"period": 55, "lag": 2},
                 "name": "Hull 55 lag 2 base", "register": true},
    "data": {"asset_id": "KO", "timeframe": "1d",
             "window": {"start": "2021-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": "P18", "params": {"period": 55, "lag": 2},
                 "name": "Hull 55 lag 2 base", "register": True},
    "data": {"asset_id": "KO", "timeframe": "1d",
             "window": {"start": "2021-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"])

Espera en run_metrics.warnings el aviso DATA_GAP_SESSIONS (KO tiene 271 sesiones ausentes; las ventanas WMA cruzan el hueco).

Derivar una variante con lag = 3:

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

lag es parámetro de política: según la tabla de invalidación §5.3 de la especificación, la curva hma completa y sus dos wma se reutilizan (acierto de caché) y solo se rehacen política y ledger. El cache_forecast debe listar hma como reutilizable y ningún fallo esperado, igual que el derivado P02-bc0dd2 de P02 con sus umbrales. Si en cambio derivas period: 89, es parámetro de feature: hay que calcular wma(44), wma(89), la wma(9) interna y hma(89); entre dos period distintos no se comparte nada salvo coincidencias de floor(p/2).

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-p18-sweep", "correlation_id": null, "schema_version": "v1",
  "payload": {"base_strategy_id": "'"$SID"'",
    "grid": {"period": [16, 32, 55, 89, 144], "lag": [1, 2, 3]},
    "candidate_policy": {"drop_invalid": true, "drop_duplicates": true,
                         "register_strategies": true, "name_template": "{template_name} {period}/{lag}"},
    "data": {"asset_id": "KO", "timeframe": "1d",
             "window": {"start": "2021-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 especificación: 15 candidatos cartesianos, 15 válidos, 5 curvas hma (una por period) y 9 curvas wma. El resultado se consulta en GET /v1/jobs/{job_id}.

Cuánto cuesta la rejilla

Concepto Cuenta
Candidatos cartesianos 5 period × 3 lag = 15
Inválidos 0 (todos cumplen period ≥ 4 y lag ≥ 1)
Curvas hma distintas 5: lag no entra en la FeatureSpec, factor 3×
Curvas wma sobre close {8, 16, 27, 32, 44, 55, 72, 89, 144} = 9, no 10: wma(16) sirve a hma(16) (como wma(period)) y a hma(32) (como wma(floor(32/2)))
Cálculos de features 9 wma + 5 kernels hma = 14 para 15 candidatos
Memoria de features en 1min (632.000 barras) 14 × 5,06 MB ≈ 70,8 MB

La trampa de memoria de P18

Calcular la WMA como (ventanas * pesos).sum(axis=1) sobre sliding_window_view con period = 144 y 632.000 barras materializa un temporal de 632.000 × 144 × 8 bytes ≈ 728 MB, casi todo el presupuesto de 1 GB por worker. La especificación fija np.convolve como implementación canónica y prohíbe la recurrencia acumulativa (rompe la propiedad de ventana finita).

Qué mirar en los resultados

  • parity.verdict con NT_FEATURES: PASS esperado en features (hma con rtol 1e-10), señales, fills y equity, como en la matriz (12/12).
  • Fills de qty == 2 frente al total: deben ser todos menos el primero. Es métrica de comportamiento y de corrección a la vez.
  • Exposición neta: ±1 en el 100 % de las barras operables posteriores a first_decision_index. Cualquier 0 es un fallo.
  • Número de reversiones y duración media del tramo: lag y period grandes suavizan y reducen reversiones; con 5 bps cada reversión paga dos unidades.
  • Barras descartadas por calentamiento por candidato: compara la rejilla desde un inicio común ≥ 157.
  • Retraso de la HMA frente al precio: la especificación propone medirlo (correlación cruzada) en vez de asumir que "adelanta".

La divergencia nativa, leída con calma

En runs/parity/KO_1d_P18_5bps_NT_ONLINE_native.md: features PASS (1297 valores comparados), señales PASS (1295), fills FAIL en el fill nº 0, barra 63: signal_index 62 en el banco (VectorTA) frente a 56 en Nautilus nativo. La tabla de contexto del informe muestra por qué: el HullMovingAverage nativo ya publica valores en las barras 58–59, cuando la HMA canónica aún es NaN (su first_valid con period = 55 es 60), y desde la barra 60 los dos valores son idénticos. El lado nativo, por tanto, empieza a operar antes; las comparaciones de señales tras el warmup coinciden, pero el primer fill cae en otra barra y toda la contabilidad queda desplazada.

Es la lectura que da el propio diagnóstico: "las señales coinciden y los fills no: la divergencia es de ejecución o contabilidad, no de decisión", y el carril que la aísla es NT_INTENT_REPLAY, que reproduce las intenciones del banco y da PASS 12/12. Coincide con lo que la especificación preveía: la HMA no tiene semilla (son ventanas finitas), así que tras el warmup Nautilus coincide bit a bit, no asintóticamente; la diferencia está en el tratamiento del calentamiento.

Causalidad comprobada

La especificación define dos tests. El genérico de "futuros alterados": serie de 500 barras, close[350:] multiplicado por 2, y se exige que wma_half, wma_full, raw, hma y los objetivos hasta el índice 300 sean idénticos bit a bit, igual que los fills con fill_index ≤ 300. Y el propio de P18, más fuerte: perturbar solo close[350] y comprobar que la curva vuelve a ser idéntica desde 350 + period + m − 1 (411 con los defaults). Una recursión colada por error haría fallar este segundo test.

Para reanudar en modo continue el checkpoint son 64 números con defaults (period cierres + m − 1 valores de raw + lag valores de hma + el objetivo): el mayor del grupo, pero exacto por construcción, sin aproximación de convergencia.

Errores típicos

Error Consecuencia Cómo evitarlo
lag = 0 PolicyParamError: hma[t] == hma[t] siempre, el objetivo quedaría en 0 para siempre Usa lag ≥ 1; en un sweep se cuenta como inválido
period < 4 PolicyParamError: con floor(p/2) = 1 la WMA corta es el propio cierre y la HMA degenera Usa period ≥ 4
round(n/2) en vez de floor(n/2) Otra curva en todos los period impares (55 → 28 en vez de 27) División entera
int(sqrt(n)) en vez de round_half_up Otra curva en period = 32 (5 en vez de 6) y otro first_valid floor(x + 0,5) o Decimal con ROUND_HALF_UP
Pesos WMA descendentes Media retrasada; la HMA gira al revés en los giros wma([10,20,30], 3) = 23,3333, no 16,6667
Sumar los first_valid de las dos ramas 26 barras buenas descartadas con period = 55 Máximo para ramas paralelas, suma solo para la cadena
Olvidar m − 1 en el warmup Señales prematuras first_valid(hma) = (p−1) + (m−1)
Meter lag en la FeatureSpec Se pierde el factor 3× de caché lag es de política
En la versión online, no avanzar el buffer en barras NaN o no operables El "hace lag barras" pasa a ser "hace lag barras válidas": divergencia permanente append incondicional en cada barra cerrada

Variantes y extensiones

  • EHMA / THMA: variantes exponencial y triple de la Hull Suite; el catálogo las excluye explícitamente y vector_ta.ehma es otro indicador.
  • Solo largos: target_lots = [0, 1] con la regla −1 → 0.
  • Filtro de fuerza: exigir |hma[t] − hma[t−lag]| mayor que una fracción del ATR antes de invertir; reduce reversiones en lateral, pero es otra receta.
  • Parientes: P01 (dos EMA por nivel), P16 (EMA con banda), P14 y P19 (otras políticas de dirección continua).

Resumen

  • P18 calcula la HMA (WMA(2·WMA(C, floor(n/2)) − WMA(C, n), round_half_up(√n))) y compara hma[t] con hma[t−lag]: +1 si sube, −1 si baja, empate conserva.
  • Es de dirección continua: tras la entrada, cada fill es una reversión de 2 unidades.
  • El warmup combina máximo (ramas paralelas), suma (cadena) y lag: 62 barras con defaults, 157 en el peor candidato.
  • 15 candidatos → 5 hma y 9 wma, porque lag es de política y wma(16) se comparte.
  • Paridad PASS salvo el carril nativo, que falla en fills por un calentamiento distinto, no por la curva.

Para practicar

  1. Con el fixture, calcula a mano hma[8] a partir de raw[7] = 10,5100 y raw[8] = 10,1200, comprueba 10,2500 y decide el objetivo de t=8 contra hma[6].
  2. Repite el fixture con lag = 1: ¿en qué barra es la primera decisión? ¿Cambia alguna señal entre t=6 y t=15? Rehaz la caja y la equity final.
  3. Lanza el sweep 5×3 sobre KO 1d y NVDA 1d. Compara el número de reversiones de (16, 1) y (144, 3) y el efecto de los 5 bps en cada uno.