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, laparameter_gridy 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é
implementedospecified_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" (P01–P20) y 10 "cuantitativas" (Q01–Q10). 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):
- Los parámetros se completan con los defaults antes de calcular el hash:
{"fast":20}y{"fast":20,"slow":50}son la misma estrategia. - Claves ordenadas.
- Números no enteros normalizados (
2y2.0en un camponumberson el mismo valor,2.0). - JSON sin espacios, UTF-8.
- La
formula_versionentra 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_minutessea múltiplo de la barra. En 1h solo sirveM = 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]
- Features. Se calculan una vez por curva distinta. Un barrido 4×4 de P01 necesita 8 EMA, no 32 (dos por pareja).
- 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). - Intents. El objetivo viaja como
IntentArrays(targetint8,decision_index_valid,first_decision_index,reason). Para carteras (Q01–Q03) viaja comoWeightIntentArrayscon pesos (ver Cartera Q01–Q03). - Ledger. Calcula el delta
target[t] − posición actual. Solo hay orden cuando el objetivo cambia. - 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.md … Q03.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 enstrategy_templates_v1.jsony en la API, porque Q08 se desdobla en Q08 Ridge y Q08B CatBoost). Una instancia es plantilla + parámetros, con idP01-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¶
- Abre
catalog/strategy_catalog.jsony cuenta cuántas fichas tienenstatus: "implemented". ¿Coincide con 23? - Con la regla de canonicalización, razona por qué
{"template_id":"P01","params":{"fast":20}}produceP01-dc9260. - Pide un
derivedeP01-dc9260cambiandofasta 10 yslowa 100. Antes de enviarlo, predice qué diráshared_features_with_parent. - Envía a propósito
{"fast": 50, "slow": 20}y lee el422: ¿qué campo nombra? - ¿Por qué P12 con
range_minutes = 30no puede ejecutarse en 1h pero sí en 30min?