Saltar a contenido

P04 · Bollinger: reversión por reentrada

Campo Valor
Familia Reversión · banda de volatilidad; entra al volver a entrar en la banda y sale en la media
Features requeridas bbands (salidas middle, upper, lower; depende de sma{period} y stddev{period} sobre close) y el rol stddev para detectar la banda degenerada; columna de barra close
Parámetros y defaults period = 20, k = 2.0
Rejilla del catálogo period ∈ {10, 20, 30, 50}, k ∈ {1.5, 2.0, 2.5, 3.0} → 16 candidatos válidos; 8 curvas "caras" (4 sma + 4 stddev) y 16 recombinaciones baratas de bandas
variant_keys period, k
Restricciones period entero ≥ 2, k > 0 (si no, PolicyParamError)
first_decision_index period − 1 = 19 con defaults (49 con period = 50)
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 'stddev'". Además, el BollingerBands nativo de Nautilus usa el precio típico (H+L+C)/3, no el cierre: otra definición

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

Qué vas a aprender

  • Cómo se construyen las bandas de Bollinger con desviación poblacional (ddof = 0) y qué significa k.
  • La regla de reentrada: cada cierre se compara con la banda de su propia barra, ayer con ayer y hoy con hoy.
  • Por qué P04 es la única excepción a la convención de cruce del proyecto (y por qué la receta manda).
  • El caso degenerado stddev = 0 y por qué se detecta con la desviación y no comparando bandas.
  • Por qué cambiar k es un fallo de caché "barato": reutiliza media y desviación.

La idea en una frase

Si el precio cierra por debajo de la banda inferior y a la barra siguiente vuelve a entrar, comprar; simétrico en la banda superior para el corto; en ambos casos, salir al tocar la media.

Intuición de mercado

Las bandas de Bollinger dibujan un "pasillo" alrededor de la media móvil cuyo ancho es k desviaciones típicas recientes. Un cierre fuera del pasillo es un movimiento inusual para la volatilidad del momento. P04 no compra al salir por abajo (el movimiento podría acelerar), sino cuando el precio vuelve a entrar: la señal de que el exceso empieza a corregirse. El objetivo es modesto: volver a la media.

Analogía. Un perro con correa extensible: cuando se aleja más de lo normal no tiras en ese instante; esperas a que empiece a volver y lo acompañas hasta tus pies (la media), no más allá.

Por qué podría funcionar. En activos y horizontes con reversión (acciones grandes en intradía, divisas en rango), los excesos respecto a la volatilidad reciente tienden a corregirse parcialmente.

Cuándo falla.

  • Rupturas genuinas: el precio vuelve a la banda un momento y sigue saliendo; sin stop, el trade aguanta hasta que el precio cruce la media.
  • Cambios de régimen de volatilidad: las bandas se ensanchan con el propio movimiento y la reentrada llega tarde o nunca.
  • Ventanas planas (intradía de baja actividad): la desviación es 0, las bandas colapsan y la receta se niega a entrar.

Sesgos típicos al evaluarla.

  • Ejecutar al precio de la banda tocada intrabar: un fill a lower[t] porque low[t] < lower[t] es una orden límite que nadie colocó. El fill es open[t+1].
  • Pocos trades: con k = 3.0 y period = 50 puede haber muy pocas reentradas; exige un número mínimo antes de comparar.
  • Huecos de sesión en KO: una diferencia multi-día infla la desviación durante period barras y suprime reentradas.

Las reglas exactas

La regla canónica del catálogo: "Bandas SMA(n)±k·std(n,ddof=0). Largo cuando C[t-1]=lower[t]; corto simétrico desde la banda superior. Salir largo con C>=media y corto con C<=media. No invertir en la decisión de salida." Nota de ingeniería: "Caso std=0: no entrada."

degenerate[t]  :=  stddev[t] == 0.0
enter_long[t]  :=  C[t-1] <  L[t-1]  and  C[t] >= L[t]  and  not degenerate[t]
enter_short[t] :=  C[t-1] >  U[t-1]  and  C[t] <= U[t]  and  not degenerate[t]
exit_long[t]   :=  C[t] >= M[t]
exit_short[t]  :=  C[t] <= M[t]
Situación en el cierre de t Objetivo target[t] Motivo (reason)
t < period − 1 (bandas NaN) 0 WARMUP
largo (+1) y C[t] >= M[t] 0 EXIT
largo (+1) y C[t] < M[t] +1 HOLD
corto (−1) y C[t] <= M[t] 0 EXIT
corto (−1) y C[t] > M[t] −1 HOLD
plano y enter_long +1 ENTRY
plano y enter_short −1 ENTRY
plano sin reentrada, o stddev == 0 0 HOLD
bandas NaN tardías el anterior HOLD
barra no operable (is_tradable = False) se congela el publicado NOT_TRADABLE
  • Entrada: solo desde plano y solo por reentrada. < estricta ayer (estar fuera de verdad) y >= hoy (volver incluye tocar la banda exactamente).
  • Salida: por la media; prioridad absoluta sobre las entradas de la misma barra.
  • Reversión: prohibida ("No invertir en la decisión de salida"). Estando corto, una reentrada larga no hace nada.
  • Filtro: stddev == 0 exacto anula las dos entradas (con las bandas colapsadas, entrada y salida caerían en la misma barra: coste puro).
# decisión en el cierre de t, fill en el open de t+1
state = 0 ; pC = pL = pU = NaN
para cada barra t:
    si t < period - 1:             target = 0                           # WARMUP
    si no, si state == +1:         target = 0 si C[t] >= M[t] si no +1  # salida primero
    si no, si state == -1:         target = 0 si C[t] <= M[t] si no -1
    si no (plano):
        si stddev[t] > 0 y pC <  pL y C[t] >= L[t]:  target = +1
        si no, si stddev[t] > 0 y pC >  pU y C[t] <= U[t]: target = -1
        si no:                                      target = 0
    si target != state:
        orden(target - state)  -> fill a open[t+1]  # siempre ±1
    state = target ; pC, pL, pU = C[t], L[t], U[t]  # se guardan aunque sean NaN

La única excepción a la convención de cruce

El proyecto define "cruce" como a[t] > b[t] and a[t−1] <= b[t−1] (§5.1, D-09 revisada), y así lo usa P02. P04 escribe literalmente sus desigualdades en la regla del catálogo (< en t−1, >= en t), y la receta manda sobre la convención. Es la única excepción documentada.

Banda de ayer con cierre de ayer

Comparar C[t−1] con L[t] (la banda de hoy) mezcla dos instantes y produce señales fantasma cuando la banda se ensancha. Por eso la política online memoriza tres escalares: C[t−1], L[t−1] y U[t−1].

Los indicadores que usa

Feature Fórmula first_valid
sma{n} media de close[t−n+1..t] n − 1
stddev{n} desviación poblacional (ddof = 0) de la misma ventana; varianza negativa minúscula limitada a 0 con VARIANCE_CLIP_ATOL = 1e−12 n − 1
bbands{n,k} middle = sma, upper = middle + k·stddev, lower = middle − k·stddev n − 1

sma y stddev son dependencias paralelas (misma ventana): no se suman. first_decision_index = period − 1 (19 con defaults), sin +1 aunque la regla mire t−1 (D-12): en esa barra la banda previa es NaN, ninguna entrada puede dispararse y la decisión "no entrar" es legítima.

Por qué stddev == 0 y no "bandas iguales a la media". Con aritmética f64 el redondeo no es simétrico en las fronteras de potencias de dos: con middle = 1024,0, k = 1,5 y stddev = 6,56e−14 > 0, upper redondea a 1024,0 exacto y lower no. Una comprobación "upper == middle o lower == middle" bloquearía entradas con desviación positiva. La spec lo documenta en §4.b.7.

Detalle numérico en Indicadores paso a paso e Indicadores y features.

Ejemplo barra a barra

Fixture §6 de docs/estrategias/P04.md: period = 3, k = 1,0 (fuera de rejilla a propósito, para calcular la desviación a mano), cash_inicial = 1.000,00, tick 0,01, sin costes. first_decision_index = 2.

Barras 0–1 idénticas a la 2 (O=100,00, H=100,20, L=99,80, C=100,00) con bandas NaN.

t O H L C middle stddev upper lower target orden fill pos. PnL acum. sin costes* equity con 5 bps*
2 100,00 100,20 99,80 100,00 100,000000 0,000000 100,000000 100,000000 0 HOLD (degenerada) 0 0,00 1.000,00
3 100,00 100,20 99,80 100,00 100,000000 0,000000 100,000000 100,000000 0 HOLD (degenerada) 0 0,00 1.000,00
4 99,50 99,70 96,80 97,00 99,000000 1,414214 100,414214 97,585786 0 HOLD 0 0,00 1.000,00
5 97,50 99,20 97,30 99,00 98,666667 1,247219 99,913886 97,419448 +1 ENTRY BUY 1 0 0,00 1.000,00
6 99,50 101,20 99,30 101,00 99,000000 1,632993 100,632993 97,367007 0 EXIT SELL 1 99,50 +1 1,50 1.001,45
7 100,50 100,70 95,80 96,00 98,666667 2,054805 100,721471 96,611862 −1 ENTRY SELL 1 100,50 0 1,00 1.000,90
8 96,50 100,20 96,30 100,00 99,000000 2,160247 101,160247 96,839753 −1 HOLD (reentrada larga ignorada) 96,50 −1 −2,50 997,35
9 100,50 101,20 100,30 101,00 99,000000 2,160247 101,160247 96,839753 −1 HOLD −1 −3,50 996,35
10 101,50 103,20 101,30 103,00 101,333333 1,247219 102,580552 100,086114 −1 HOLD −1 −5,50 994,35
11 103,50 105,20 103,30 105,00 103,000000 1,632993 104,632993 101,367007 −1 HOLD −1 −7,50 992,35
12 105,50 110,20 105,30 110,00 106,000000 2,943920 108,943920 103,056080 −1 HOLD −1 −12,50 987,35
13 109,50 109,70 103,80 104,00 106,333333 2,624669 108,958003 103,708664 0 EXIT BUY 1 −1 −6,50 993,35
14 104,50 104,70 102,80 103,00 105,666667 3,091206 108,757873 102,575461 0 HOLD 104,50 0 −7,00 992,80
15 103,50 103,70 99,80 100,00 102,333333 1,699673 104,033007 100,633660 0 HOLD 0 −7,00 992,80

OHLC, bandas, objetivos, motivos y fills son literales del fixture. *El fixture da la equity en los cierres 5, 6, 7, 8, 12 y la final (993,00); la columna "PnL acumulado" rellena las demás barras con la regla del ledger equity = cash + posición · close (cálculo de este manual, trivial: caja 900,50 → 1.001,00 → 1.097,50 → 993,00) y coincide con el fixture donde este da cifra. "Equity con 5 bps" 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 Precio Cuenta Comisión
1 BUY 1 6 99,50 0,04975 0,05
2 SELL 1 7 100,50 0,05025 0,05
3 SELL 1 8 96,50 0,04825 0,05
4 BUY 1 14 104,50 0,05225 0,05

Lo que hay que ver en la tabla:

  1. Barras 2–3: ventana plana → stddev = 0 → las tres bandas valen 100 y degenerate anula las entradas. Las dos salidas son ciertas a la vez, pero el estado es 0: resultado determinista.
  2. Barra 5: C[4] = 97,00 < L[4] = 97,585786 (fuera ayer) y C[5] = 99,00 >= L[5] = 97,419448 (dentro hoy) → largo. Fill en el open de 6 (99,50).
  3. Barra 6: 101,00 >= 99,000000 → salida. Largo de una barra, +1,00 en caja.
  4. Barra 7: C[6] = 101,00 > U[6] = 100,632993 y C[7] = 96,00 <= U[7] → corto desde plano.
  5. Barra 8: la reentrada larga es cierta (96,00 < 96,611862 y 100,00 >= 96,839753), pero el estado es −1 y 100,00 <= 99,00 es falso: no se invierte. Es la barra que distingue P04 de una estrategia que diera la vuelta.
  6. Barras 9–12: el precio sube contra el corto y la media no se alcanza: −12,50 en el peor momento. Sin stop, la receta aguanta.
  7. Barra 13: la reentrada corta y la salida del corto son ciertas a la vez; manda la salida → 0. Equity final 993,00: la receta pierde en este fixture.

Las cuentas de las barras clave

Barra 4 (banda de "ayer" para t=5):
  ventana (100; 100; 97)   middle = 297/3 = 99,000000
  desviaciones (1; 1; −2)  varianza = (1 + 1 + 4)/3 = 2      stddev = √2 = 1,414214
  lower[4] = 99 − 1,414214 = 97,585786
Barra 5 (entrada larga):
  ventana (100; 97; 99)    middle = 296/3 = 98,666667
  desviaciones (4/3; −5/3; 1/3)  varianza = (16 + 25 + 1)/27 = 42/27 = 1,555556
  stddev = 1,247219        lower[5] = 97,419448 ; upper[5] = 99,913886
  97,00 < 97,585786  y  99,00 >= 97,419448  y  stddev > 0  -> ENTRY +1
Barra 13 (salida del corto):
  ventana (105; 110; 104)  middle = 319/3 = 106,333333
  varianza = (16 + 121 + 49)/27 = 186/27 = 6,888889   stddev = 2,624669
  104,00 <= 106,333333 -> EXIT (la reentrada corta, también cierta, no se mira)
Caja:  1.000,00 − 99,50  = 900,50   (fill 1, open[6]);  equity cierre 6 = 900,50 + 101,00 = 1.001,50
       900,50 + 100,50   = 1.001,00 (fill 2, open[7])
       1.001,00 + 96,50  = 1.097,50 (fill 3, open[8]);  equity cierre 12 = 1.097,50 − 110,00 = 987,50
       1.097,50 − 104,50 = 993,00   (fill 4, open[14]); equity final 993,00

Diagrama

flowchart LR
    W(["WARMUP: t < period-1"]) -->|"bandas válidas"| FLAT["Plano 0"]
    FLAT -->|"C[t-1] < L[t-1] y C >= L · BUY 1"| L["Largo +1"]
    FLAT -->|"C[t-1] > U[t-1] y C <= U · SELL 1"| S["Corto -1"]
    FLAT -->|"sin reentrada o stddev == 0"| FLAT
    L -->|"C >= media · SELL 1"| FLAT
    S -->|"C <= media · BUY 1"| FLAT
    L -->|"C < media"| L
    S -->|"C > media (reentradas ignoradas)"| S

Como en P02, no hay flecha directa entre largo y corto: todas las transiciones mueven 1 unidad.

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-p04-0001",
  "correlation_id": null,
  "schema_version": "v1",
  "payload": {
    "strategy": {"template_id": "P04", "params": {"period": 20, "k": 2.0},
                 "name": "Bollinger 20/2 reentrada", "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": "P04", "params": {"period": 20, "k": 2.0}, "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"])

NT_FEATURES funciona porque recibe las bandas ya calculadas (D-14). La ruta NT_ONLINE con indicator_provider: "native" es UNSUPPORTED: Nautilus no ofrece stddev y su Bollinger usa el precio típico.

Derivar una variante con bandas más anchas:

curl -sS -X POST "$BASE/v1/strategies/$SID/derive" -H 'Content-Type: application/json' -d '{
  "request_id": "manual-p04-derive", "correlation_id": null, "schema_version": "v1",
  "payload": {"param_overrides": {"k": 3.0}, "name": "Bollinger 20/3 reentrada",
              "slug": "bollinger-20-3", "tags": ["sensibilidad"]}}' \
  | jq '.payload.strategy_id, .payload.cache_forecast'

k es parámetro de feature, pero con invalidación parcial (fila "BB k2 → k3" de §5.3 de la spec): la clave de bbands{20, 3.0} es nueva (fallo), mientras que sma{20} y stddev{20} son aciertos; solo se recombinan middle ± k·stddev (cuatro operaciones por barra) y se rehacen política y ledger. Cambiar period sí es un fallo completo: sma y stddev nuevas.

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-p04-sweep", "correlation_id": null, "schema_version": "v1",
  "payload": {"base_strategy_id": "'"$SID"'",
    "grid": {"period": [10, 20, 30, 50], "k": [1.5, 2.0, 2.5, 3.0]},
    "candidate_policy": {"drop_invalid": true, "drop_duplicates": true,
                         "register_strategies": true, "name_template": "{template_name} {period}/{k}"},
    "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: 16 candidatos, 16 válidos. Resultados en GET /v1/jobs/{job_id}.

Cuánto cuesta la rejilla

Concepto Cuenta
Candidatos cartesianos 4 period × 4 k = 16
Inválidos 0 (todos period ≥ 2, k > 0)
Curvas caras (ventanas rodantes) 4 sma + 4 stddev = 8, frente a 32 sin factorizar (ahorro del 75 %)
Entradas de caché bbands 16 (la clave incluye k), pero cada una es una recombinación trivial
Políticas y ledgers 16 y 16

"En P04, distintos multiplicadores comparten media y desviación": las 16 claves finales no se comparten, pero el trabajo pesado (la stddev rodante) sí.

Qué mirar en los resultados

  • parity.verdict: PASS esperado en NT_FEATURES, NT_INTENT_REPLAY y NT_ONLINE(vectorta).
  • Número de reentradas y salidas; barras en +1/−1/0: P04 pasa mucho tiempo plano. Los fills igualan la suma de |delta| (nunca invierte).
  • Fracción de barras degeneradas (stddev == 0): en intradía puede ser grande y explicar por sí sola pocos trades.
  • Anchura relativa media (upper − lower)/middle y fracción de cierres fuera de bandas: descriptivas, no de selección.
  • Número mínimo de trades antes de ordenar candidatos (§10.2).
  • feature_build_ns desglosado en sma, stddev y recombinación: stddev domina.

Causalidad comprobada

Test de "futuros alterados" de la spec: NVDA 1d, 600 barras, {20, 2.0}; a partir de t0 = 400 se inyecta un régimen de alta volatilidad (close[u] += 12·(−1)^u). Deben quedar idénticos bit a bit en [0, 400] las tres bandas, objetivos, motivos, el estado interno (_target y los tres previous) y los fills con fill_index ≤ 400. Una desviación con sumas acumuladas sobre toda la serie (la fórmula "rápida" E[x²] − E[x]², que la referencia canónica rechaza) fallaría este test.

Checkpoint mínimo para continue: la ventana completa close[t0−period .. t0−1] (media y desviación rodantes no se resumen en un escalar, a diferencia de las EMA de P01), el objetivo vigente y _previous_close, _previous_lower, _previous_upper.

Errores típicos

Error Consecuencia Cómo evitarlo
period = 1 PolicyParamError: stddev ≡ 0, nunca entraría period ≥ 2
k <= 0 PolicyParamError: bandas colapsadas k > 0
Desviación muestral (ddof = 1) Bandas un factor √(n/(n−1)) más anchas: otras señales ddof = 0, como dice la regla
Comparar C[t−1] con L[t] Señales fantasma al ensancharse la banda Cada cierre con la banda de su barra
Usar la convención §5.1 (> hoy, <= ayer) Otra estrategia en los empates P04 usa < ayer y >= hoy, literal del catálogo
Detectar std = 0 comparando bandas Entradas bloqueadas con desviación positiva stddev == 0.0 exacto
Fill al precio de la banda intrabar Resultado optimista e irreal Fill en open[t+1]
Bollinger nativo de Nautilus Media sobre (H+L+C)/3, no sobre close Usa bandas precalculadas (NT_FEATURES)

Variantes y extensiones

  • Entrada al tocar la banda (sin esperar reentrada): más señales y más riesgo de "cuchillo que cae"; es otra receta.
  • Salida en la banda opuesta en lugar de la media.
  • Solo largos con target_lots = [0, 1].
  • Filtro de régimen: evitar reentradas cuando la anchura de banda se expande, justo lo contrario de P05, que busca la ruptura tras la compresión.
  • Parientes: P02 RSI (retorno desde extremos sobre un oscilador), P16 Keltner (bandas con ATR en vez de desviación típica).

Resumen

  • Bandas SMA(n) ± k·std(n, ddof=0); largo al volver a entrar por abajo, corto al volver a entrar por arriba, salida en la media.
  • Cada cierre se compara con la banda de su propia barra; desigualdades literales del catálogo (única excepción a la convención de cruce).
  • Nunca invierte: la salida tiene prioridad y todas las transiciones mueven 1 unidad.
  • stddev == 0 exacto bloquea la entrada; no se infiere comparando bandas.
  • 16 candidatos cuestan 8 ventanas rodantes; cambiar k solo recombina bandas.

Para practicar

  1. Con el fixture, calcula a mano middle[6], stddev[6], upper[6] y lower[6] (ventana 97; 99; 101) y comprueba 99,000000; 1,632993; 100,632993; 97,367007. ¿Por qué con estos valores la barra 7 abre corto?
  2. Rehaz la tabla suponiendo que la receta invirtiera en la barra 8 (de −1 a +1 con la reentrada larga). ¿Qué fills cambian y cuál sería la equity final sin costes?
  3. Lanza el backtest base y deriva k = 3.0. Comprueba en cache_forecast qué features se reutilizan, y compara número de fills y equity final entre ambos.