PáginasSeries de indicadores · 15 / 29
Series de indicadores
Cuando termina un backtest, la vista Resultados de pruebas del IDE muestra gráficos de tu ejecución. Las series de indicadores te permiten añadir a esos gráficos series temporales adicionales que has calculado tú mismo —medias móviles, RSI, histogramas MACD, señales personalizadas— para que aparezcan junto a las velas OHLCV, en sus propios paneles, o por símbolo en ejecuciones con varios símbolos.
QuantCraft no ofrece indicadores integrados. No hay API de SMA, MACD ni RSI en la plataforma. Tú implementas la matemática (o usas tu propia biblioteca), construyes una lista de filas {"time": ..., "value": ...} y se la entregas al recolector del runtime. En el backtest del IDE, el motor posee una ChartIndicatorSeries en runtime.chart_indicators y adjunta chart.to_payload() al resultado como chart_indicator_series. El módulo normaliza los tiempos y almacena las series; no calcula los valores de los indicadores por ti.
Las series siempre se entregan desde on_finish. Lo mejor es recopilar los valores mientras la ejecución recorre las barras; consulta Inicio rápido.
El IDE convierte cada entrada en una superposición de línea o de histograma en el panel del gráfico correspondiente.
Cómo funciona
- Calculas una o más series de indicadores (p. ej. una SMA a partir de los cierres, un RSI con tu fórmula) como una lista de filas
{"time": ..., "value": ...}. - Añades cada serie al recolector del runtime con opciones de visualización (qué panel, línea o histograma, nombre/color/símbolo opcionales).
- Al final del backtest, el motor fusiona esa colección en el resultado bajo
chart_indicator_series. - El gráfico del IDE en Test Results muestra cada serie con las opciones que elegiste.
Lee el recolector como un atributo de módulo, no como un nombre importado:
import quantcraft.process.runtime as rt
ci = rt.chart_indicators # assigned by the engine before on_initchart_indicators es una instancia de ChartIndicatorSeries. Todo lo que añadas con .add(...) (con puntos que tú construiste) se adjunta a Test Results mediante el to_payload() del motor. No construyas una ChartIndicatorSeries() privada para los Test Results del IDE: usa el recolector del runtime.
¿Por qué
rt.chart_indicatorsy nofrom ... import chart_indicators? Los manejadores del runtime son por sesión, así que unfrom ... importenlaza una instantánea. En el archivo raíz del algoritmo no importa: el motor vuelve a enlazar el nombre tras cargar tu script. En un módulo auxiliar dividido sí importa: los módulos auxiliares permanecen en caché ensys.modules, de modo que el nombre conserva el recolector de la sesión que estaba activa en la primera importación, lo que es incorrecto para la siguiente ejecución en el mismo proceso (forward / masiva). Consulta Manejadores de módulo frente a nombres importados.
Inicio rápido
El patrón recomendado es registrar el valor donde la estrategia lo calcula —en on_bar / on_tick— y usar on_finish solo para entregar las series recopiladas. Los valores trazados son entonces exactamente aquellos con los que operó la estrategia, en el reloj de ejecución propio del motor, sin recalcular ni realinear.
import sys
import quantcraft.process.runtime as rt
PERIOD = 45
# symbol -> parallel lists, appended in lockstep so indices can never drift
_bar_times: dict[str, list] = {}
_sma: dict[str, list[float]] = {}
def on_bar(bar_index, bar, fundamentals=None, symbol=None):
sym = (symbol or "").upper()
# `bar.close[i]` returns None past the start of the loaded series.
closes = [bar.close[i] for i in range(PERIOD)]
# One append per list, per bar — NaN while warming up, never a skip.
value = float("nan") if None in closes else sum(closes) / PERIOD
_bar_times.setdefault(sym, []).append(bar["t"]) # UTC bar time, verbatim
_sma.setdefault(sym, []).append(value)
... # trade on `value`
def on_finish(bars, symbol=None, bars_by_symbol=None):
ci = rt.chart_indicators
if ci is None:
return
for sym in sorted(_bar_times):
times = _bar_times[sym]
vals = _sma.get(sym, [])
if len(times) != len(vals): # cheap guard against drift
print(f"{sym}: {len(times)} times vs {len(vals)} values", file=sys.stderr)
continue
# NaN warmup rows and unparseable times are dropped by `add`.
points = [{"time": t, "value": v} for t, v in zip(times, vals)]
ci.add(
points,
pane="main",
render="line",
line_style="solid",
id=f"sma_{PERIOD}_{sym}",
name=f"SMA({PERIOD}) {sym}",
color="#ff9800",
symbol=sym,
)El gráfico de resultados muestra entonces tu SMA(45) como una superposición continua sobre el gráfico de velas.
Deriva id y name de la misma constante con la que opera la estrategia (PERIOD arriba), nunca de un número fijo: una leyenda obsoleta es indistinguible de un trazado obsoleto.
Para las reglas de rutas de importación (quantcraft.* en todas partes; las rutas antiguas ide.* siguen resolviéndose para scripts existentes), consulta Referencia.
Mantener alineados tiempos y valores
add empareja time y value por posición, así que un desajuste desplaza toda la línea en silencio: se dibuja, pero frente a las velas equivocadas.
- Añade el tiempo y el valor en la misma instrucción, en cada barra. Dos listas paralelas rellenadas a ritmos distintos y luego unidas con
zipes el error clásico:ziptrunca a la lista más corta, así que un hueco de calentamiento de N barras empareja los primeros N tiempos con valores que pertenecen a las barras N…: la línea queda N barras a la izquierda, sin ningún error. - Mientras el indicador se calienta, añade
float("nan")en lugar de saltar.adddescarta los valores no finitos por ti (consulta Construir series), así que las filas de calentamiento desaparecen del trazado mientras las dos listas siguen teniendo la misma longitud. - Pasa el
tde la barra tal cual.coerce_chart_timeacepta cadenas ISO, segundos/milisegundos epoch ydatetime. Las cadenas sin zona horaria —incluidas las fechas diarias de QuantCraft B2 como"2024-01-02"— se leen como UTC. Un objetodatetimeingenuo, en cambio, se lee como hora local, lo que desplaza cada punto. Las filas de barras llevan unten UTC; consérvalo. addya normaliza cadatimey descarta los valores no finitos, así que prefiltrar concoerce_chart_time/math.isnanen tu propio bucle es opcional.
Cada
returnenon_finishes silencioso. La ejecución sigue teniendo éxito y Test Results se sigue mostrando, solo que sin tus series. Cuando una serie no aparece, hazprint(..., file=sys.stderr)en cada protección; el mensaje aparece en el Panel de salida.
Alternativa: recalcular al final
Para un indicador que no seguiste barra a barra, derívalo en on_finish a partir de bars —la ventana ejecutada del símbolo de reloj, la más antigua primero, las mismas filas que dibuja el gráfico de Test Results— o de bars_by_symbol. Toma las marcas de tiempo de las mismas filas que alimentaste al cálculo, para que una fila descartada o filtrada no pueda desincronizar la correspondencia:
import quantcraft.process.runtime as rt
PERIOD = 20
def on_finish(bars, symbol=None, bars_by_symbol=None):
ci = rt.chart_indicators
if ci is None or not bars:
return
closes = [float(b["close"]) for b in bars]
points = [
{"time": bars[i]["t"], "value": sum(closes[i - PERIOD + 1 : i + 1]) / PERIOD}
for i in range(PERIOD - 1, len(bars))
]
if not points:
return
ci.add(
points,
pane="main",
render="line",
line_style="dotted",
id=f"sma_{PERIOD}",
name=f"SMA({PERIOD})",
symbol=symbol,
)No construyas series a partir de runtime.qc_ohlcv: es None en una ejecución con objetivo de screener y, donde sí está definido, abarca el rango completo cargado incluido el calentamiento, no la ventana ejecutada que dibuja el gráfico.
Construir series: la lista points
Cada serie es una lista de dicts que construiste tras ejecutar tu propia lógica de indicador. Cada fila necesita:
| Clave | Tipo | Notas |
|---|---|---|
time | número / datetime / cadena ISO | Se normaliza a segundos Unix (int). Pasa la marca de tiempo de la barra —bar["t"] en un callback, bars[i]["t"] en on_finish— tal cual. |
value | número | Debe ser finito: las filas NaN / inf se descartan, que es como desaparecen del trazado las filas de calentamiento. |
Las demás claves del dict se ignoran, y las filas con tiempo inválido o valor no finito se descartan en silencio, incluidos los tiempos numéricos demasiado pequeños para ser segundos epoch (por debajo de 100.000.000). Una fila que no sea un dict es un error: add lanza TypeError: points[i] must be a dict with 'time' and 'value' keys.
Opciones de serie (por llamada a add)
Las defines una vez por serie, al llamar a ci.add(...):
| Argumento | Obligatorio | Valores |
|---|---|---|
pane | sí | "main": superposición en el gráfico de precios/velas. "separate": su propio panel bajo el gráfico (bueno para RSI, MACD, etc.). |
render | sí | "line" o "histogram". |
line_style | con render="line" | "solid" o "dotted". Obligatorio para líneas: omitirlo lanza ValueError: When render is "line", line_style must be "solid" or "dotted". Omítelo en histogramas (se ignora). |
id | no | Identificador estable de la serie; se genera automáticamente si no das uno. Útil para conservar el mismo color/orden entre ejecuciones. |
name | no | Nombre mostrado en la leyenda del gráfico. Por defecto es id. |
color | no | Indicación de color (p. ej. "#22c55e"). |
symbol | no | Ticker en mayúsculas (p. ej. "MSFT"). En backtests con varios símbolos, fija la serie al gráfico de ese símbolo. Omítelo para el símbolo de reloj / principal. Una ejecución Screener no tiene símbolo de reloj, así que pasa siempre symbol allí. |
pane_id | no | Con pane="separate", las series con el mismo pane_id comparten un subpanel (p. ej. RSI más líneas de referencia 30/70 punteadas). Omítelo para una serie por panel. Se ignora con pane="main". |
Dónde aparece cada serie
Dos ajustes deciden juntos dónde se dibuja la serie:
panecontrola la ubicación vertical:"main": se sitúa sobre las velas de su gráfico."separate": obtiene su propio panel bajo las velas.
symbolcontrola en qué gráfico de una ejecución con varios símbolos:- omitido: se adjunta al gráfico del símbolo principal / de reloj (coincide con el valor por defecto en ejecuciones de un solo símbolo).
"AAPL"(etc.): se adjunta solo al gráfico de ese ticker.
En backtests de un solo símbolo puedes ignorar symbol por completo; todo va en el único gráfico.
Líneas frente a histogramas
Son opciones de visualización para series que ya calculaste. Ejemplos de lo que podrías trazar:
Superposiciones de línea (render="line")
Úsalas para series continuas que se alinean con los cierres:
- Medias móviles (SMA, EMA): normalmente
pane="main". - Bandas de Bollinger / Keltner:
pane="main", varias series. - RSI / Estocástico / ADX:
pane="separate"para que no se comprima la escala de precios.
Elige line_style:
"solid"para indicadores principales."dotted"para series secundarias / de referencia (una MA lenta junto a una rápida, una banda superior).
# rsi_points must come from your own RSI calculation
rt.chart_indicators.add(rsi_points, pane="separate", render="line", line_style="solid",
id="rsi_14", name="RSI(14)")Histogramas (render="histogram")
Úsalos para series de estilo barra/columna (valores positivos y negativos dibujados como barras):
- Histograma MACD, puntuaciones tipo volumen, fuerza de la señal, una vez calculados.
rt.chart_indicators.add(
[{"time": t, "value": v} for t, v in zip(hist_times, hist_vals)],
pane="separate",
render="histogram",
id="macd_hist",
name="MACD Hist",
)line_style no se usa en histogramas: omítelo.
Backtests con varios símbolos
En ejecuciones con varios símbolos, cada ticker cargado tiene su propio gráfico en Test Results. Usa symbol="..." para enviar una serie a un gráfico concreto y llama a add una vez por ticker.
Acumular por barra lo resuelve gratis. on_bar ya se ejecuta una vez por ticker, así que indexar los valores recopilados por symbol —como en Inicio rápido— produce una serie por pestaña sin trabajo extra. on_finish se ejecuta una sola vez al final de toda la ejecución, y su argumento bars es solo el del símbolo de reloj: si construyes indicadores solo con bars y pasas symbol=symbol, todas las series caen en el gráfico del símbolo de reloj y las demás pestañas no tienen series de indicadores.
Solución para la vía de recálculo: recorre bars_by_symbol, calcula los puntos a partir de las barras de cada símbolo y pasa ese ticker en cada add:
def on_finish(bars, symbol=None, bars_by_symbol=None):
ci = rt.chart_indicators
if ci is None:
return
sources = bars_by_symbol if bars_by_symbol else {(symbol or "").upper(): bars}
for ticker, ticker_bars in sources.items():
sma_points = compute_sma(ticker_bars, window=20) # your function
ci.add(
sma_points,
pane="main",
render="line",
line_style="solid",
id=f"sma_20_{ticker.lower()}",
name="SMA(20)",
symbol=ticker,
)Las series sin symbol van en el gráfico de reloj / principal. Las series con symbol van solo en el gráfico de ese ticker.
Consejo: conserva el mismo
name(p. ej."SMA(20)") en todos los símbolos; elidy elpane_id(cuando se use) deben ser únicos por símbolo.
Agrupar series en un subpanel (pane_id)
Con pane="separate", cada llamada a add(...) obtiene normalmente su propio subpanel. Pasa el mismo pane_id para apilar series relacionadas, por ejemplo un RSI con líneas punteadas de sobrecompra/sobreventa:
import quantcraft.process.runtime as rt
RSI_PANE = "rsi"
def on_finish(bars, symbol=None, bars_by_symbol=None):
ci = rt.chart_indicators
if ci is None or not bars:
return
bar_times = [b["t"] for b in bars]
rsi_points = compute_rsi(bars) # your function
ci.add(
rsi_points,
pane="separate",
render="line",
line_style="solid",
id="rsi_14",
name="RSI(14)",
pane_id=RSI_PANE,
)
ci.add(
[{"time": t, "value": 70.0} for t in bar_times],
pane="separate",
render="line",
line_style="dotted",
id="rsi_ob",
name="Overbought",
pane_id=RSI_PANE,
color="#94a3b8",
)
ci.add(
[{"time": t, "value": 30.0} for t in bar_times],
pane="separate",
render="line",
line_style="dotted",
id="rsi_os",
name="Oversold",
pane_id=RSI_PANE,
color="#94a3b8",
)En ejecuciones con varios símbolos, usa un pane_id por símbolo (p. ej. f"rsi_{ticker}") y un id único por ticker (p. ej. f"rsi_{ticker.lower()}").
Varias series a la vez
Puedes añadir tantas series como quieras: llama a add(...) una vez por serie, cada una con puntos que tú calculaste:
ci.add(sma20_points, pane="main", render="line", line_style="solid", id="sma_20", name="SMA(20)")
ci.add(sma50_points, pane="main", render="line", line_style="dotted", id="sma_50", name="SMA(50)")
ci.add(rsi_points, pane="separate", render="line", line_style="solid", id="rsi_14", name="RSI(14)")
ci.add(macd_hist, pane="separate", render="histogram", id="macd", name="MACD Hist")Construir series fuera de un backtest
to_result_dict(key=DEFAULT_RESULT_KEY) y merge_into(result, key=...) son ayudantes para notebooks / ejecutores personalizados que poseen el dict de resultado: devuelven {key: to_payload()}, o fusionan eso en result. DEFAULT_RESULT_KEY es "chart_indicator_series". No los usa la ruta de backtest del IDE (el motor lee to_payload() de runtime.chart_indicators).
Otros miembros: ChartIndicatorSeries(max_entries=None) conserva solo las N series más recientes cuando se define max_entries (las ejecuciones masivas lo definen en el recolector compartido); add(...) devuelve el SeriesEntry almacenado; entries() lista los objetos SeriesEntry añadidos hasta ahora.
from quantcraft.indicator_series import ChartIndicatorSeries
# Points come from your own indicator logic, not from this module
points = [
{"time": 1_700_000_000, "value": 150.0},
{"time": 1_700_008_640, "value": 151.2},
]
series = ChartIndicatorSeries()
series.add(
points,
pane="main",
render="line",
line_style="dotted",
id="sma_20",
name="SMA(20)",
)
test_result = {"success": True, "stdout": "..."}
test_result = series.merge_into(test_result)
# test_result["chart_indicator_series"] -> ready for the chart clientDentro de un backtest normal del IDE, usa solo rt.chart_indicators y .add(...): no construyas un recolector aparte ni hagas merge_into sobre un dict local.
Forma de la carga útil del resultado (como referencia)
Cada entrada de chart_indicator_series termina así:
| Campo | Descripción |
|---|---|
id, name, pane, render, points | Exactamente lo que pasaste a add(...). |
line_style | "solid" / "dotted" para líneas, null para histogramas. |
color | Presente solo si pasaste color=. |
symbol | Presente solo si pasaste symbol=: ticker en mayúsculas para que el IDE pueda situarlo en el gráfico correcto. |
pane_id | Presente solo cuando pane="separate" y pasaste un pane_id= no vacío. |
Cada fila de points se normaliza a solo {"time": int, "value": float}.
Consejos y trampas
- QuantCraft traza; tú calculas. Usa pandas, numpy, TA-Lib o tus propios bucles, y luego pasa los
pointsresultantes aci.add(...). - Calcula donde operas, emite en
on_finish. Registrar(hora de barra, valor)por símbolo mientras la ejecución recorre las barras traza exactamente aquello sobre lo que actuó la estrategia. Recalcula a partir de losbarsdeon_finishsolo los indicadores que no seguiste por barra. - Lee el recolector como
rt.chart_indicators.from quantcraft.process.runtime import chart_indicatorsenlaza una instantánea: seguro en el archivo raíz del algoritmo, incorrecto en un módulo auxiliar dividido. - Añade
NaNdurante el calentamiento, no saltes. Saltar filas en una lista y no en la otra desplaza toda la línea;adddescarta las filas no finitas por ti. - Usa las marcas de tiempo de las barras para
time, tal cual. Eltde una barra de backtest suele ser una cadena ISO (p. ej."2024-06-10T13:30:00+00:00", o"2024-01-02"para barras diarias B2) y está en UTC. No supongas queint(b["t"])funciona, y no lo reconstruyas como undatetimeingenuo: se leería como hora local y desplazaría cada punto. - Los
returndeon_finishson silenciosos. Sin series y sin error suele significar unreturntemprano; imprime ensys.stderren cada protección. - No desde
qc_ohlcv. EsNoneen una ejecución con objetivo de screener y, si no, incluye barras de calentamiento que el gráfico nunca dibuja. - Los
idestables ayudan al estilo. Reutilizar el mismoidentre ejecuciones permite al gráfico mantener estables colores y orden, y derivarid/namede la misma constante que usa la estrategia mantiene honesta la leyenda. - Elige el panel correcto. Los indicadores acotados (RSI 0–100) pertenecen a
"separate": ponerlos en"main"deformará la escala de precios. - Histograma = sin
line_style; línea =line_styleobligatorio. Una línea sin él lanzaValueError. - Ejecuciones Screener: pasa siempre
symbol. No hay símbolo de reloj al que recurrir.

