Programar estrategias en Java

Emite señales de operación e información, configura órdenes y adjunta metadatos de gráfico.

Una estrategia de QTSurfer consume datos de mercado, actualiza indicadores y estado, y emite señales. Esta guía cubre la emisión de señales — el punto donde una observación se convierte en una instrucción de operar o en un dato para inspeccionar más tarde — y recibir un comando desde fuera de una ejecución en vivo.

Para la API completa de clases, usa el Javadoc del motor, en particular el paquete de señales de estrategia. Para redactar con ayuda de un agente, instala la skill mantenida qtsurfer-java-strategy:

npx skills add QTSurfer/strategy-skills --skill qtsurfer-java-strategy

La skill también cubre cómo elegir una clase base de estrategia, configurar indicadores y gestionar el estado por instrumento. Una vez listo el fuente, compílalo y valídalo a través de la API.

Los helpers de señal de esta guía no están atados a una clase Java: los cuerpos { } de una estrategia QTScript (beta) — una forma compacta de escribir una estrategia que deja fuera la clase, los imports y el listener — llaman a emitBuy, emitSell, emitInfo y emitSignal exactamente como se muestra aquí.

Señales de ejecución y señales de información

Estas familias de señales tienen efectos distintos:

SeñalPropósito¿Causa una operación?
BuySignalExpresa una instrucción de compra y su configuración de ordenSí
SellSignalExpresa una instrucción de venta y su configuración de ordenSí
InfoStrategySignalRegistra indicadores, diagnósticos o metadatos de visualizaciónNo

Las tres están en com.wualabs.qtsurfer.engine.strategy.event.signal, y una estrategia que nombra una la importa desde ahí: import com.wualabs.qtsurfer.engine.strategy.event.signal.InfoStrategySignal;, igual que BuySignal más abajo. Si solo usas emitBuy, emitSell y emitInfo, que nunca nombran el tipo, no hace falta ningún import.

Una señal de información etiquetada BUY sigue siendo solo información. A la inversa, emitBuy(price) emite una señal de compra ejecutable aunque no lleve metadatos de gráfico adjuntos.

El ejemplo del README emite deliberadamente ambas. Publica una señal de información en cada actualización de ventana para poder inspeccionar la serie de indicadores, pero solo emite una compra o venta cuando cruzan las medias móviles:

import com.wualabs.qtsurfer.engine.strategy.event.signal.InfoStrategySignal;

InfoStrategySignal signal = createInfoSignal();
signal.set("fast", fast);
signal.set("slow", slow);

if (isBullish && !wasBullish) {
    emitBuy(price);
} else if (!isBullish && wasBullish) {
    emitSell(price);
}

emitSignal(signal);

Helpers de señal

Dentro de un AbstractWindowListener, el listener ya conoce su estrategia y su instrumento:

HelperResultado
emitBuy(price)Crea y emite de inmediato una BuySignal de mercado
emitSell(price)Crea y emite de inmediato una SellSignal de mercado
createBuySignal(price)Crea una señal de compra para personalizarla antes de emitirla
createSellSignal(price)Crea una señal de venta para personalizarla antes de emitirla
createInfoSignal()Crea una señal de información para rellenarla antes de emitirla
emitInfo(key, values...)Crea, rellena y emite de inmediato una señal de información
emitSignal(signal)Emite una señal creada o personalizada por el listener

A nivel de la clase de estrategia, los helpers de operación equivalentes reciben el instrumento de forma explícita: emitBuy(instrument, price), emitSell(instrument, price), createBuySignal(instrument, price) y createSellSignal(instrument, price). Usa createInfoStrategySignal(instrument) cuando construyas ahí una señal de información.

El price que se pasa a los helpers de operación inmediatos es el precio de referencia actual de la estrategia. La señal es market por defecto; cuando una señal cambia a limit, ese precio pasa a ser su precio límite.

Personalizar señales de compra y venta

Los helpers inmediatos aceptan deliberadamente solo un precio. Para configurar una orden, crea su señal, fija las opciones necesarias y emítela exactamente una vez:

import com.wualabs.qtsurfer.engine.exchange.trade.OrderFlag;
import com.wualabs.qtsurfer.engine.strategy.event.signal.BuySignal;
import com.wualabs.qtsurfer.engine.strategy.event.signal.MarketHintSignal.OrderKind;

BuySignal buy = createBuySignal(price);
buy.setOrderKind(OrderKind.limit);
buy.setMaxTries(3);
buy.setFlags(OrderFlag.GTC);
buy.set("reason", "ema-cross");
emitSignal(buy);

BuySignal y SellSignal heredan estas opciones de MarketHintSignal, la clase base común y referencia autorizada de sus métodos:

MétodoSignificado
setOrderKind(OrderKind.market)Orden a mercado; es el valor por defecto
setOrderKind(OrderKind.limit)Orden límite al price de la señal
setMaxTries(n)Número máximo de intentos para una compra límite; n debe ser positivo
setFlags(flags...)Indicadores de orden como FOK, IOC o GTC; el soporte real depende del exchange
setSellPercent(percent)Porcentaje de la posición a cerrar; pensado para ejecución multi-entrada, por defecto 100
setStopPrice(price)Stop de protección fijo que se arma tras ejecutarse la entrada
setStopLimitPrice(price)Precio límite opcional para ese stop fijo; sin él, el stop sale a mercado
setTrailPercent(percent)Stop de protección dinámico, expresado como porcentaje desde el extremo de precio favorable en curso
setStopCondition(condition)Predicado en vivo que condiciona un stop fijo o dinámico gestionado por el motor
set(key, values...)Metadatos arbitrarios de analítica, procedencia o visualización que viajan con la señal

Trata stop y stopTrailing como tipos de orden gestionados por el motor. El código de la estrategia debe expresar el riesgo de protección en la señal de entrada con setStopPrice o setTrailPercent, en lugar de emitir una orden de stop independiente.

Stops de protección

Una entrada en largo puede armar un stop fijo como parte de la misma señal:

BuySignal buy = createBuySignal(price);
buy.setStopPrice(price * 0.95);
emitSignal(buy);

Usa también setStopLimitPrice cuando la salida de protección deba ser stop-limit en lugar de stop-market. Un stop dinámico sigue el extremo favorable y se dispara tras el retroceso porcentual configurado:

BuySignal buy = createBuySignal(price);
buy.setTrailPercent(2.0);
emitSignal(buy);

Los mismos campos se aplican de forma simétrica a una entrada en corto. Una condición de stop la evalúa el motor de forma repetida y puede suprimir el stop hasta que una condición más amplia de la estrategia lo permita. Es lógica de estrategia en vivo, no datos de señal serializables.

Dimensionado

Una estrategia dice cuándo y en qué sentido, no cuánto. emitBuy(price) y emitSell(price) llevan un precio y nada más, y ninguna llamada pone una cantidad ni un nocional en una señal. El tamaño de cada entrada es un ajuste de la ejecución, percentAmountToLock: el porcentaje del saldo libre de la cuenta que bloquea una entrada, por encima de 0 y hasta 100.

Dónde se da depende de la ejecución: baseConfig en un backtest o un barrido, el bloque paper en una ejecución en vivo en papel. Si se omite, un backtest pone todo el saldo libre en cada entrada y la cuenta en papel de una ejecución en vivo bloquea el 10 %. Para ver cómo se comporta una estrategia con otro tamaño, vuelve a ejecutarla con otro percentAmountToLock; la clase no cambia.

Información y metadatos de gráfico

createInfoSignal() es azúcar sintáctico local al listener: crea una InfoStrategySignal ya vinculada a la estrategia y el instrumento actuales. Rellénala con set y emítela cuando esté lista:

import com.wualabs.qtsurfer.engine.strategy.event.signal.InfoStrategySignal;

InfoStrategySignal signal = createInfoSignal();
signal.set("price", price);
signal.set("fast", fast);
signal.set("slow", slow);
emitSignal(signal);

set almacena un valor directamente. Una lista par de pares nombre/valor crea un objeto anidado bajo la clave dada, que es por lo que los marcadores de gráfico usan esta forma:

signal.set("_m",
    "position", "belowBar",
    "shape", "arrowUp",
    "color", "#26a69a",
    "text", "BUY");

Las posiciones de marcador que usa la visualización estándar son aboveBar, belowBar e inBar; las formas portables son circle, arrowUp, arrowDown y square. Prefijar una propiedad con _ la reserva como metadato de control en lugar de una serie normal representada, tal como hace _m aquí.

Todo lo que haces set en una señal es su data, y se publica con la señal en una ejecución en vivo: quien pueda leer la ejecución puede leerlo, así que en una ejecución public es público. Una señal cuyo data pese más de 8 KiB (8.192 bytes de su JSON) no se empuja por el canal WebSocket; GET /live/{runId}/signals la devuelve entera de todos modos.

Para un único valor, emitInfo es la forma más corta:

emitInfo("zscore", zscore);

También acepta pares nombre/valor anidados:

emitInfo("averages", "fast", fast, "slow", slow);

emitInfo acepta los mismos argumentos en un cuerpo QTScript — el README muestra el ejemplo de media móvil de arriba con sus marcadores de gráfico escritos así.

Usa la forma más larga createInfoSignal() cuando un evento necesite varios valores de primer nivel o metadatos de marcador. Las señales de información son útiles para explicar una decisión, pero nunca sustituyen al emitBuy o emitSell correspondiente cuando la estrategia tiene que operar.

Listeners de ventana y el valor anterior

Un listener de ventana se dispara una vez cuando cierra su periodo, y onChange recibe el valor del indicador en ese momento, actual, y el que tenía cuando cerró la ventana anterior, prev: el close[1] de otras herramientas. Un cruce, o cualquier otra cosa que compare un cierre con el anterior, no necesita guardar nada en el StateStore.

Esto entra cuando el RSI vuelve a subir por encima de 30, una sola vez, en la ventana en la que ocurre:

import com.wualabs.qtsurfer.engine.core.state.StateStore;
import com.wualabs.qtsurfer.engine.indicators.helpers.WindowTimeRTIndicator.WindowTime;
import com.wualabs.qtsurfer.engine.indicators.helpers.group.InstrumentGroupRTIndicator;
import com.wualabs.qtsurfer.engine.strategy.AbstractTickerStrategy;
import com.wualabs.qtsurfer.engine.strategy.AbstractWindowListener;

public class RsiReentry extends AbstractTickerStrategy {

    @Override
    protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
        indicators
            .addPrice()
            .rsi(14)
            .window("rsi14", WindowTime.m1, new Reentry(this, indicators));
    }

    private class Reentry extends AbstractWindowListener {

        public Reentry(AbstractTickerStrategy strategy, InstrumentGroupRTIndicator indicators) {
            super(strategy, indicators);
        }

        @Override
        public void onChange(StateStore store, double prev, double actual) {
            if (prev < 30 && actual >= 30) emitBuy(indicators.getValue("price"));
        }
    }
}

En una estrategia kline, una ventana sobre close te da el cierre de la barra anterior como prev, que es lo que necesita el rango verdadero junto a los high y low de la propia barra. Los campos de la barra también son indicadores, y se registran a partir de un KlineValueSource:

import com.wualabs.qtsurfer.engine.core.KlineInterval;
import com.wualabs.qtsurfer.engine.core.state.StateStore;
import com.wualabs.qtsurfer.engine.indicators.core.KlineValueSource;
import com.wualabs.qtsurfer.engine.indicators.helpers.WindowTimeRTIndicator.WindowTime;
import com.wualabs.qtsurfer.engine.indicators.helpers.group.InstrumentGroupRTIndicator;
import com.wualabs.qtsurfer.engine.strategy.AbstractKlineStrategy;
import com.wualabs.qtsurfer.engine.strategy.AbstractWindowListener;

public class TrueRange extends AbstractKlineStrategy {

    @Override
    public KlineInterval getInterval() {
        return KlineInterval.s1;
    }

    @Override
    protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
        indicators
            .add("high", KlineValueSource.High)
            .add("low", KlineValueSource.Low)
            .add("close", KlineValueSource.Close)
            .window("close", WindowTime.m1, new Range(this, indicators));
    }

    private class Range extends AbstractWindowListener {

        public Range(AbstractKlineStrategy strategy, InstrumentGroupRTIndicator indicators) {
            super(strategy, indicators);
        }

        @Override
        public void onChange(StateStore store, double prev, double actual) {
            double high = indicators.getValue("high");
            double low = indicators.getValue("low");
            double trueRange = Math.max(high - low, Math.max(Math.abs(high - prev), Math.abs(low - prev)));
            emitInfo("trueRange", trueRange);
        }
    }
}

Una estrategia kline en Java tiene que sobrescribir getInterval(): la clase base lo deja abstracto, y una fuente sin él se rechaza. En un backtest el ancho de barra no es lo que devuelve, sino el cadence con el que se prepararon los datos, así que KlineInterval.s1 sirve para cualquier ejecución; a una estrategia kline de QTScript se le escribe este método.

La primera vez que se dispara una ventana no hay una ventana anterior con la que comparar: prev es el valor que tenía el indicador cuando se abrió esa primera ventana. Una regla que necesite dos cierres reales debe dejar pasar el primer disparo, y un indicador con periodo de calentamiento, como un RSI o una EMA, tampoco se ha asentado en sus primeras ventanas. Reserva el StateStore para lo que una ventana no te puede dar: una marca, un contador, un valor de varias ventanas atrás. Un listener que solo necesita el store, prev y actual puede ser una lambda; uno que emite una señal extiende AbstractWindowListener, como arriba.

Leer indicadores desde update()

Una estrategia que sobrescribe update() en lugar de usar ventanas avanza ella misma los indicadores con updateIndicators(instrument, source). Devuelve el mapa de indicadores del instrumento, un InstrumentMapRTIndicator, no un indicador: escribe var, o este tipo, y lee cada indicador de él por su nombre.

import com.wualabs.qtsurfer.engine.core.Ticker;
import com.wualabs.qtsurfer.engine.core.instrument.Instrument;
import com.wualabs.qtsurfer.engine.indicators.helpers.group.InstrumentMapRTIndicator;

@Override
public void update(Ticker ticker) {
    Instrument instrument = ticker.instrument();
    updateInstrument(instrument, ticker.timestamp());
    InstrumentMapRTIndicator indicators = updateIndicators(instrument, ticker);

    if (!indicators.getReadOnlyExisting("emaSlow").isReady()) return;   // espera al calentamiento

    double fast = indicators.getValue("emaFast");
    double slow = indicators.getValue("emaSlow");
    // ...
}

getReadOnlyExisting(name) devuelve una vista de solo lectura: lee el valor del indicador vivo e ignora update(...), sin dar error, así que nada en update() puede cambiar por accidente el valor de un indicador compartido, que es lo que quiere una estrategia que solo lee valores. getExisting(name) devuelve el indicador vivo: un update o un reset sobre él cambia la serie para todos los que la leen, y nada lo avisa. Las dos lanzan una excepción si no hay ningún indicador con ese nombre. No escribas el tipo del mapa como RichRTIndicator, la interfaz para escribir un indicador propio: el mapa no la implementa, y el compilador responde incompatible types. El cannot find symbol para getExisting que le sigue es una consecuencia, y la respuesta lo dice debajo. RTIndicator sí compila, porque el mapa lo es, pero oculta getExisting y getReadOnlyExisting, que solo tiene el mapa.

updateIndicators por sí solo no publica señales: una estrategia que lo llama desde su propio update() opera con normalidad y deja vacías las señales que guarda un backtest. Para conservarlas, llama primero a super.update(ticker), que ya hace updateInstrument y updateIndicators, y lee los indicadores con getRTIndicator(instrument, name). Devuelve el indicador vivo dentro de un Optional, no una vista de solo lectura; .map(RTIndicator::ro) da la vista.

Recibir comandos

El dueño de una ejecución en vivo puede indicarle un comando desde fuera — POST /live/{runId}/commands — mientras sigue corriendo, sin reiniciarla. Para actuar sobre uno, implementa CommandRequestHandler:

import com.wualabs.qtsurfer.engine.strategy.event.request.CommandRequest;
import com.wualabs.qtsurfer.engine.strategy.event.request.CommandRequestHandler;

public class MyStrategy extends AbstractTickerStrategy implements CommandRequestHandler {

    @Override
    public void handle(CommandRequest request) {
        if ("flatten".equals(request.getCommand())) {
            // cierra la posición, cancela órdenes pendientes, lo que signifique "flatten" para esta estrategia
        }
    }
}

handle corre en el mismo hilo que update(), justo antes del evento de mercado al que apunta el comando, así que ve el estado de la estrategia exactamente como estaba en ese momento y puede llamar a cualquier cosa que pueda llamar update() — leer indicadores, emitir una señal, cambiar campos internos. Una RuntimeException que lance se captura y se cuenta, igual que una de update(); un Error desmonta la ejecución.

Un comando es siempre una cadena simple, y es transitorio. También puede llevar un objeto properties de tu elección, junto a command en el cuerpo de la petición — no params, que sigue siendo lo que fija una ejecución al arrancar y lo que cambia PUT /live/{runId}/params. Cada propiedad aterriza como una entrada de primer nivel en el propio mapa de CommandRequest, así que lee una directamente de request por su nombre — request.get("<clave>") — ninguna clave está prohibida, ya que el texto del comando se guarda aparte (getCommand() lo lee, sin verse afectado por nada de esto). Un valor conserva el tipo JSON con el que llegó, así que asignarlo a un campo String cuando quien llama envió un número o un objeto lanza un ClassCastException dentro de handle; un cuerpo onCommand de QTScript lee el mismo valor con $command.<clave> en su lugar, que siempre lo ensancha a un String (null para una clave ausente, nunca un fallo de conversión). Un comando, y sus propiedades, no se guardan como parte de la ejecución: una réplica que se reinicia reproduce solo el último tramo de datos de mercado, y un comando de antes de esa ventana simplemente no le llega.

Un comando no lleva instrumento asociado como sí lo hace update(); cuando sus propias propiedades nombran uno, alcanza el store de ese instrumento con getStateStore(String):

@Override
public void handle(CommandRequest request) {
    String instrument = request.get("instrument");
    if (instrument != null) {
        getStateStore(instrument).set("flattened");
    }
}

Ni un campo @StrategyProperty ni un StateStore escrito desde dentro de handle son duraderos. Ambos cambian de inmediato, en memoria, igual que cualquier otra asignación, pero ninguno se escribe en el conjunto de parámetros guardado de la ejecución — una réplica que se reinicia (o una que arranca después, y nunca corrió handle para ese comando) parte de lo último que fijó PUT /live/{runId}/params, no de lo que asignó un comando. La única escritura duradera es una llamada real a PUT /live/{runId}/params, desde fuera de la ejecución — una estrategia no puede llamar a su propia API REST desde dentro de handle.

Una ejecución cuya estrategia no implementa CommandRequestHandler responde todos los comandos con un 409 — implementar la interfaz es lo que hace que POST /live/{runId}/commands haga algo.

Una estrategia QTScript también la implementa, mediante su propia sección onCommand { } (consulta QTScript) — la plataforma reconoce la clase generada como CommandRequestHandler de la misma forma que reconoce esta.

Consulta Comandos para la forma de la petición/respuesta y los códigos de error.

Los indicadores pro

Los indicadores pro vienen con los planes Pro y Elite. En Java son los métodos de Pro, en com.wualabs.qtsurfer.engine.indicators.pro: Pro.on(indicators) recibe el grupo de indicadores que recibe setupIndicators, y cada llamada registra un indicador pro con los mismos nombres, formas e ids que lista la tabla de QTScript. Pro.on(indicators).adx(14) registra adx14 y, a su lado, adx14PlusDI y adx14MinusDI. Una ventana va sobre el principal (adx14), y el listener lee los demás por nombre.

Leen barras enteras, así que úsalos en una estrategia kline. Esta compra cuando el ADX dice que hay tendencia y la línea plus está por encima de la minus, y vende cuando está por debajo:

import com.wualabs.qtsurfer.engine.core.KlineInterval;
import com.wualabs.qtsurfer.engine.core.state.StateStore;
import com.wualabs.qtsurfer.engine.indicators.core.KlineValueSource;
import com.wualabs.qtsurfer.engine.indicators.helpers.WindowTimeRTIndicator.WindowTime;
import com.wualabs.qtsurfer.engine.indicators.helpers.group.InstrumentGroupRTIndicator;
import com.wualabs.qtsurfer.engine.indicators.pro.Pro;
import com.wualabs.qtsurfer.engine.strategy.AbstractKlineStrategy;
import com.wualabs.qtsurfer.engine.strategy.AbstractWindowListener;

public class AdxTrend extends AbstractKlineStrategy {

  @Override
  public KlineInterval getInterval() {
    return KlineInterval.s1;
  }

  @Override
  protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
    indicators.addPrice();
    indicators.add("open", KlineValueSource.Open);
    indicators.add("high", KlineValueSource.High);
    indicators.add("low", KlineValueSource.Low);
    indicators.add("close", KlineValueSource.Close);
    indicators.add("volume", KlineValueSource.Volume);
    Pro.on(indicators).adx(14);
    indicators.window("adx14", WindowTime.m1, new TrendListener(this, indicators));
  }

  private class TrendListener extends AbstractWindowListener {

    TrendListener(AbstractKlineStrategy strategy, InstrumentGroupRTIndicator indicators) {
      super(strategy, indicators);
    }

    @Override
    public void onChange(StateStore store, double prev, double actual) {
      double close = indicators.getValue("close");
      double plus = indicators.getValue("adx14PlusDI");
      double minus = indicators.getValue("adx14MinusDI");
      if (actual > 20 && plus > minus) emitBuy(close);
      if (actual > 20 && plus < minus) emitSell(close);
    }
  }
}

En una estrategia ticker, un indicador pro no se rechaza, pero sus valores no son los de la barra y no significan nada, y validar o ejecutar la estrategia devuelve un aviso del motor que lo dice.

Registrar una estrategia que los usa se acepta en cualquier plan, y la respuesta de POST /strategy lleva requires: ["indicators.pro"]. Ejecutarla en un plan que no los incluye se rechaza con 402, al validarla, al lanzar un backtest o un barrido con ella y al lanzar una ejecución en vivo con ella. El mensaje nombra la primera llamada que hace la estrategia y la línea de tu fuente, aquí strategy uses pro indicators (Pro.adx, line 25); your plan: free. La comprobación lee la estrategia compilada, así que cualquier otra mención de una clase del paquete pro cuenta igual, y entonces el mensaje nombra la clase. El estado, el cuerpo y el upgradeUrl son los que describe la guía de QTScript.

Ver también

  • QTScript (beta) — las mismas estrategias escritas sin la ceremonia.
  • Estrategias — compila, valida, lista y recupera una estrategia, en cualquiera de los dos lenguajes.