Saltar a contenido

Cómo se define una estrategia

Qué vas a aprender

  • La diferencia entre una plantilla (receta del catálogo) y una instancia (receta + parámetros concretos, con su propio identificador).
  • Por qué el identificador de una estrategia es un hash determinista y el nombre es libre.
  • Qué son los defaults, la parameter_grid y las restricciones de cada plantilla.
  • Cómo viaja una decisión por la tubería features → política → intents → fills → ledger.
  • Qué significa que una receta esté implemented o specified_not_implemented, y por qué Q04–Q10 (hito H5) están fuera.

Dónde está la verdad

El catálogo vive en catalog/strategy_catalog.json (versión 1.0.0, as_of 2026-09-15). Cada receta tiene además una especificación larga en docs/estrategias/<ID>.md con reglas, pseudocódigo, un fixture calculado a mano y criterios de aceptación. Este capítulo resume ambos; el contrato de la API está en docs/API.md §4.

1. Una analogía: la receta y el plato

Piensa en un libro de cocina. La receta "bizcocho" dice qué hacer (batir, hornear) y deja huecos: cuántos huevos, cuántos minutos. Cuando rellenas los huecos —4 huevos, 35 minutos— tienes un plato concreto, que puedes repetir, comparar con otro y apuntar en tu cuaderno.

En FinazTradingEngine:

Cocina Finaz Ejemplo
Receta del libro Plantilla (template_id) P01 "Cruce de medias EMA"
Huecos de la receta Parámetros (params) fast, slow
Cantidades por defecto defaults fast = 20, slow = 50
Variantes que merece la pena probar parameter_grid fast ∈ {5,10,20,40}, slow ∈ {50,100,150,200}
Plato concreto Instancia (strategy_id) P01-dc9260 = P01 con 20/50

La plantilla es inmutable (solo cambia si sube su formula_version). La instancia es lo que se ejecuta: se hace backtest, se deriva y se barre.

2. El catálogo: 30 fichas, dos familias

El fichero catalog/strategy_catalog.json contiene 30 fichas: 20 "populares" (P01P20) y 10 "cuantitativas" (Q01Q10). La API, en cambio, sirve 31 plantillas: no lee ese fichero directamente, sino catalog/strategy_templates_v1.json (campo count: 31), que genera finazbench/strategies/build_templates.py a partir del catálogo. Ese generador desdobla Q08 en dos plantillas: Q08 (Ridge) y Q08B (CatBoost, con requires_extra: "ml"), porque el soporte de CatBoost es una propiedad de la plantilla y no un parámetro (docs/API.md §6.2). El catálogo original no se modifica. Si catboost no está instalado, la API marca Q08B con el motivo MISSING_OPTIONAL_DEPENDENCY (finazbench/api/routers/capabilities.py).

Fichero Entradas Quién lo usa
catalog/strategy_catalog.json 30 (P01–P20, Q01–Q10) Fuente de verdad del catálogo
catalog/strategy_templates_v1.json 31 (añade Q08B) La API de backtesting (/v1/catalog/strategies)

El catálogo no es un ranking

El propio fichero lo declara: "rank_claim": "representative_selection_not_global_popularity_ranking". Es una selección representativa para medir motores, no "las 20 estrategias más rentables del mundo".

Cada ficha tiene esta forma (P01, literal del catálogo):

{
  "id": "P01",
  "name": "Cruce de medias EMA",
  "category": "popular",
  "data_requirements": ["OHLC"],
  "features": ["ema_fast", "ema_slow"],
  "canonical_rule": "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. …",
  "defaults": { "fast": 20, "slow": 50 },
  "parameter_grid": { "fast": [5, 10, 20, 40], "slow": [50, 100, 150, 200] },
  "engineering_notes": "Validar fast<slow. Precalcular cada EMA única … Un barrido de 4×4 parejas requiere 8 curvas, no 32.",
  "implementation_milestone": "H1",
  "expected_algorithmic_cost": "O(N)",
  "status": "implemented"
}
Campo Para qué sirve
canonical_rule La regla en una frase. Es la fuente de verdad: la especificación larga la desarrolla, no la cambia.
features Qué series calculadas necesita (medias, bandas, ATR…).
defaults Parámetros que se usan si no dices nada.
parameter_grid Rejilla oficial para barridos (sweeps).
implementation_milestone Hito en que se construyó: H1, H2, H3…
status implemented o specified_not_implemented.

Estado por familia

flowchart LR
    CAT[catalog/strategy_catalog.json<br/>30 fichas] --> P[P01–P20<br/>populares]
    CAT --> Q[Q01–Q10<br/>cuantitativas]
    P --> PI[20 implemented<br/>H1: P01 P02 P04 P06 P11<br/>H2: el resto]
    Q --> QI[Q01–Q03 implemented<br/>H3: cartera]
    Q --> QN[Q04–Q10<br/>specified_not_implemented<br/>H3/H4 → fuera de alcance H5]
Grupo Recetas Estado
H1 (fase A) P01, P02, P04, P06, P11 implemented, con políticas en finazbench/policy/ y ledger SIM-S
H2 (fase B) P03, P05, P07–P10, P12–P20 implemented
H3 (fase C, cartera) Q01, Q02, Q03 implemented (panel, pesos, ledger de cartera)
Fuera de alcance Q04 Kalman, Q05 PCA, Q06 ERC, Q07 HMM, Q08 Ridge/CatBoost, Q09 carry, Q10 OFI specified_not_implemented

¿Por qué Q04–Q10 no están?

Dirección decidió que el bloque que las contiene (H5) no se implementa por ahora. Q04–Q06 tienen un borrador de especificación en docs/estrategias/ marcado como no revisado; Q07–Q10 no tienen documento. Además Q09 (futuros individuales) y Q10 (libro L1) no tienen datos en el proyecto. Algunas de esas ideas (Ridge, CatBoost, Ledoit-Wolf, ERC) reaparecen en los motores de modelos de la plataforma como jobs: ver Modelos G4–G8.

3. La instancia: identidad por hash

Una petición de backtest siempre referencia una instancia: por strategy_id, o inline con {template_id, params} que la API resuelve.

Cómo se calcula el strategy_id

strategy_id    = "<template_id>-<hash6>"
hash6          = primeros 6 hex de BLAKE2b-128( canonical_json )
canonical_json = { "template_id": "P01",
                   "formula_version": "1.0.0",
                   "params": <params completos, claves ordenadas, floats normalizados> }

Reglas de canonicalización (las que hacen que el hash sea estable entre máquinas y lenguajes):

  1. Los parámetros se completan con los defaults antes de calcular el hash: {"fast":20} y {"fast":20,"slow":50} son la misma estrategia.
  2. Claves ordenadas.
  3. Números no enteros normalizados (2 y 2.0 en un campo number son el mismo valor, 2.0).
  4. JSON sin espacios, UTF-8.
  5. La formula_version entra en el hash.

Valores reales (verificados por tests/strategies/test_identity.py):

Plantilla + params strategy_id
P01 {"fast":20,"slow":50} P01-dc9260
P01 {"fast":20,"slow":100} P01-09dd21
P02 {"period":14,"lower":30,"upper":70} P02-be06fa
P02 {"period":14,"lower":25,"upper":75} P02-bc0dd2

Consecuencia práctica

Si tú en tu portátil y un compañero en el servidor inventáis por separado "EMA 20/50", ambos obtenéis P01-dc9260 sin coordinaros. No hay contador central ni base de datos que asigne ids. Y como el id identifica el contenido, sirve de clave de caché y de firma de comparación.

El nombre es libre; el id nunca cambia

  • El id es para máquinas y reproducibilidad.
  • El nombre (name, slug, description, tags) es para personas: se corrige, se traduce, se renombra.

Regla dura: cambiar un parámetro no modifica la instancia: crea otra. Eso es exactamente lo que hace derive.

Crear y derivar por API

BASE=http://localhost:8000
# Crear la instancia base
curl -sS -X POST "$BASE/v1/strategies" -H 'Content-Type: application/json' \
  -d @examples/api/06a_create_p01_20_50.request.json | jq '.payload | {strategy_id, params, already_existed}'

# Derivar cambiando solo slow
curl -sS -X POST "$BASE/v1/strategies/P01-dc9260/derive" -H 'Content-Type: application/json' \
  -d @examples/api/06b_derive_p01_20_100.request.json | jq '.payload | {strategy_id, derived_from, cache_forecast}'
import json, requests
BASE = "http://localhost:8000"
body = json.load(open("examples/api/06a_create_p01_20_50.request.json"))
r = requests.post(f"{BASE}/v1/strategies", json=body).json()
print(r["payload"]["strategy_id"])          # P01-dc9260

body = json.load(open("examples/api/06b_derive_p01_20_100.request.json"))
r = requests.post(f"{BASE}/v1/strategies/P01-dc9260/derive", json=body).json()
print(r["payload"]["param_diff_vs_parent"]) # {'slow': {'from': 50, 'to': 100}}

La petición de creación (real, 06a):

{
  "schema_version": "v1",
  "payload": {
    "template_id": "P01",
    "params": { "fast": 20, "slow": 50 },
    "name": "EMA cross 20/50 base",
    "slug": "ema-cross-20-50",
    "description": "Punto de partida de la fase A sobre NVDA.",
    "tags": ["fase-A"]
  }
}

La respuesta de la derivación (real, 06b, recortada):

{
  "payload": {
    "strategy_id": "P01-09dd21",
    "params": { "fast": 20, "slow": 100 },
    "derived_from": "P01-dc9260",
    "param_diff_vs_parent": { "slow": { "from": 50, "to": 100 } },
    "lineage": ["P01-dc9260", "P01-09dd21"],
    "shared_features_with_parent": ["ema_fast"],
    "invalidated_features_vs_parent": ["ema_slow"],
    "cache_forecast": {
      "expected_feature_hits": ["ema_fast"],
      "expected_feature_misses": ["ema_slow"],
      "expected_result_hit": false,
      "explanation": "Cambiar ['slow'] invalida ['ema_slow']; ['ema_fast'] se reutiliza. Política y ledger se rehacen siempre."
    },
    "storage_path": "results/strategies/P01-09dd21.json",
    "already_existed": false
  }
}

Fíjate en cache_forecast: la API sabe qué features dependen de qué parámetro, así que anticipa qué se reutiliza. Si hubieras cambiado solo los umbrales de P02 (30/70 → 25/75), la RSI entera se reutilizaría: los umbrales son parámetros de política, no de feature.

Cambio Se comparte Se invalida
P02 lower/upper 30/70 → 25/75 rsi
P01 slow 50 → 100 ema_fast ema_slow
P04 k 2.0 → 3.0 sma, std_population bb_upper, bb_lower
P06 multiplier 3.0 → 2.0 atr supertrend_bands, direction
P11 exit 10 → 20 prior_rolling_high prior_rolling_low

Versionado de fórmula

Si una plantilla sube su formula_version, las instancias viejas conservan su id (es un hecho histórico), se marcan stale_formula: true y no se recalculan en silencio: un backtest sobre ellas responde 409 STRATEGY_FORMULA_STALE salvo que pidas allow_stale: true.

4. Parámetros: defaults, rejilla y restricciones

Cada plantilla publica un param_schema y unas restricciones legibles. Si las violas, la API responde 422 STRATEGY_PARAMS_INVALID nombrando el campo (respuesta real, 91_error_strategy_params_invalid):

{
  "error": {
    "code": "STRATEGY_PARAMS_INVALID",
    "message": "Los parámetros no cumplen el schema del template P01.",
    "details": {
      "template_id": "P01",
      "violations": [
        { "field": "slow", "rule": "fast < slow", "value": 20,
          "related": { "fast": 20 },
          "message": "slow debe ser estrictamente mayor que fast." }
      ]
    },
    "retriable": false
  }
}
Plantilla Restricciones (extracto de docs/API.md §4.8)
P01 fast < slow; ambos enteros ≥ 2
P02 0 < lower < 50 < upper < 100; period ≥ 2
P04 period ≥ 2; k > 0
P06 period ≥ 2; multiplier > 0
P11 exit < entry
P15 exit_adx < entry_adx
Q03 exit < entry < stop; z_window ≤ formation

Un parámetro que no existe en la plantilla también es 422 (rule: "unknown_field"): no se aceptan campos ad hoc.

La rejilla depende del timeframe

La rejilla del catálogo es la misma para todos los timeframes en casi todas las recetas, pero algunas tienen condiciones ligadas al tamaño de la barra:

  • P12 (ORB) y P13 (VWAP) exigen que range_minutes / min_minutes sea múltiplo de la barra. En 1h solo sirve M = 60. Con los defaults (range_minutes = 30, min_minutes = 30) un backtest en 1h es UNSUPPORTED: 30 minutos no es múltiplo de 60. Por eso el índice de especificaciones cuenta los candidatos "por TF" (P12: 4·2·4·3·2·1 válidos según timeframe).
  • P11 excluye la pareja (entry=20, exit=20): 15 cartesianos, 14 válidos.
  • P15 excluye exit_adx >= entry_adx: 24 cartesianos, 20 válidos.

Historia mínima: first_decision_index

Ninguna estrategia puede decidir antes de que sus features sean válidas. La regla (decisión D-12) es first_decision_index = max(first_valid de las features), sin "+1". Con los defaults:

Receta fdi (defaults) Peor caso de la rejilla
P01 49 199
P02 14 28
P04 19 49
P05 271 291
P06 9 20
P11 20 100
P18 62 157
Q01/Q02/Q03 252 252 / 252 / 504

Fuente: docs/DATOS_POR_ESTRATEGIA.md §2.

5. La tubería: de una barra a un apunte contable

Todas las recetas P siguen el mismo camino. Merece la pena memorizarlo, porque explica casi todas las cifras que verás en la API.

flowchart LR
    B[Barras OHLCV<br/>BarArrays] --> F[Features<br/>EMA, RSI, ATR…<br/>proveedor canonical/vectorta]
    F --> P[Política<br/>regla canónica]
    P --> I[IntentArrays<br/>target ∈ −1,0,+1<br/>reason, decision_index_valid]
    I --> L[Ledger SIM-S<br/>delta = target − posición]
    L --> FL[Fills<br/>next-open t+1]
    L --> E[Positions / Equity<br/>enteros en céntimos]
  1. Features. Se calculan una vez por curva distinta. Un barrido 4×4 de P01 necesita 8 EMA, no 32 (dos por pareja).
  2. Política. Aplica la regla canónica y produce un objetivo por barra: target ∈ {−1, 0, +1} (corto, plano, largo), con un código de razón (WARMUP, HOLD, ENTRY, EXIT, REVERSAL).
  3. Intents. El objetivo viaja como IntentArrays (target int8, decision_index_valid, first_decision_index, reason). Para carteras (Q01–Q03) viaja como WeightIntentArrays con pesos (ver Cartera Q01–Q03).
  4. Ledger. Calcula el delta target[t] − posición actual. Solo hay orden cuando el objetivo cambia.
  5. Fill next-open. La decisión se toma al cierre de la barra t y se ejecuta en la apertura de t+1. Precio: open[t+1] + signo(delta)·ticks_adversos·tick. Comisión en puntos básicos redondeada al céntimo half-even.

Next-open no es un detalle

Decidir con el cierre de t y ejecutar al cierre de t sería mirar el futuro: en la vida real, cuando conoces el cierre, ese precio ya no está disponible. Por eso todos los carriles (VectorTA + ledger y los adaptadores de Nautilus) usan el contrato SIM-S: fill_index = signal_index + 1, price = open[fill_index]. En Nautilus se consigue con una quote sintética de apertura en close_ts + 1 ns.

Reglas del ledger que conviene conocer

Regla Qué significa Origen
Lotes discretos {−1, 0, +1} El ledger SIM-S NumPy opera una unidad target_lots
Reversión = delta 2 Pasar de +1 a −1 genera una orden de 2 unidades (y paga comisión por 2) P01, P06
Sin liquidación final Si la serie termina con posición abierta, se valora al último cierre, no se cierra auto_liquidate_at_end: false
Última señal sin fill Una señal en la última barra no tiene open[t+1]: no se inventa un fill §3.3
Barras no operables El delta pendiente espera a la siguiente apertura operable D-25, D-41
Enteros exactos Precios en ticks enteros, caja en céntimos: la paridad entre motores es exacta, no "aproximada" D-16

Estados de una estrategia "desde plano" vs "siempre en mercado"

No todas las recetas se comportan igual. Hay dos grandes familias de máquina de estados:

stateDiagram-v2
    direction LR
    [*] --> Plano
    Plano --> Largo: entrada larga
    Plano --> Corto: entrada corta
    Largo --> Plano: salida
    Corto --> Plano: salida
    Largo --> Corto: reversión (solo P01, P03, P06, P14, P18, P19…)
    Corto --> Largo: reversión
  • Desde plano (P02, P04, P11, P16…): tras salir, se vuelve a 0; la salida tiene prioridad y no se invierte en la misma decisión. Nunca hay deltas de 2.
  • Dirección continua (P01, P03, P06, P14, P18, P19): el objetivo es +1 o −1 y se invierte directamente, con delta 2.

6. Dónde vive cada cosa

Pieza Fichero
Catálogo catalog/strategy_catalog.json
Especificación larga docs/estrategias/P01.mdQ03.md
Identidad finazbench/strategies/identity.py
Políticas H1 finazbench/policy/p01_ema_cross.py, p02_rsi.py, p04_bbands.py, p06_supertrend.py, p11_donchian.py
Ledger NumPy / Numba finazbench/ledger/sim_s_numpy.py, sim_s_numba.py
Instancias persistidas results/strategies/<strategy_id>.json

Resumen

  • Una plantilla es una receta inmutable del catálogo (30 fichas en strategy_catalog.json; 31 plantillas en strategy_templates_v1.json y en la API, porque Q08 se desdobla en Q08 Ridge y Q08B CatBoost). Una instancia es plantilla + parámetros, con id P01-dc9260.
  • El id es un hash BLAKE2b del contenido canónico: reproducible en cualquier máquina, clave de caché y de comparación. El nombre es libre.
  • Cambiar un parámetro crea otra instancia (derive), que informa de qué features comparte con su padre.
  • Los parámetros se validan con restricciones y el error nombra el campo. Algunas rejillas dependen del timeframe (P12/P13 en 1h).
  • La tubería es siempre features → política → intents → ledger → fills next-open → equity.
  • Q01–Q03 están implementadas; Q04–Q10 están especificadas pero fuera de alcance (H5).

Para practicar

  1. Abre catalog/strategy_catalog.json y cuenta cuántas fichas tienen status: "implemented". ¿Coincide con 23?
  2. Con la regla de canonicalización, razona por qué {"template_id":"P01","params":{"fast":20}} produce P01-dc9260.
  3. Pide un derive de P01-dc9260 cambiando fast a 10 y slow a 100. Antes de enviarlo, predice qué dirá shared_features_with_parent.
  4. Envía a propósito {"fast": 50, "slow": 20} y lee el 422: ¿qué campo nombra?
  5. ¿Por qué P12 con range_minutes = 30 no puede ejecutarse en 1h pero sí en 30min?