QTSurfer beta

Strategy patterns

Apply reusable filters, exits, state transitions, and market-wide calculations.

Proven patterns extracted from production and backtested legacy strategies.

Noise filtering chain

The most sophisticated signal processing pattern in the codebase. Converts a raw indicator into a clean, smoothed signal:

raw signal
  → clamp(±threshold → 0)        suppress micro-noise
    → percentChange               convert to rate of change
      → conditional(≠0, EMA(n))  only feed non-zero changes to EMA
indicators
    .add("raw", TickerValueSource.Close)
    .distance("distemas", "ema60", "ema500")
    .clamp("distemas", v -> Math.abs(v) <= 0.1, 0.0)
    .percentChange("chgDistemas")
    .conditional("smoothDistemas", "chgDistemas", v -> v != 0,
        indicators.getReadOnlyExisting("ema500"), zeroIndicator)
    .window("smoothDistemas", WindowTime.s1, new DetectorListener(this, indicators));

Why: Raw EMA-distance signals have too much noise for reliable decisions. The conditional prevents zero-padding from diluting the EMA when there’s no meaningful change.


EMA distance analysis

Use the distance between EMAs as the primary signal instead of raw EMA values. Distance is a derivative — it measures momentum of the trend, not the trend itself.

indicators
    .ema("ema60", 60)
    .ema("ema500", 500)
    .ema("ema2500", 2500)
    .distance("shortDistemas", "ema60", "ema500")   // short-term momentum
    .distance("longDistemas",  "ema500", "ema2500") // medium-term trend
    .gain("longStreakCount", "ema7500");              // macro uptrend

Entry gate: longDistemas >= 0.35 AND shortDistemas rising AND macro streak >= 300.


Trailing exit variants

Midpoint trailing (production proven, ~7% avg)

// In onChange:
double percentGain = (price - buyPrice) / buyPrice * 100;
if (percentGain > maxPcnGain) maxPcnGain = percentGain;
double trigger = maxPcnGain - (maxPcnGain - minPercentGain) / 2;
if (percentGain > minPercentGain && percentGain <= trigger) {
    emitSell(price);
}

Sells when gain retraces to the midpoint between the minimum threshold and the peak gain. Self-calibrates to the magnitude of the move.

Peak trailing

// In onChange:
if (price > maxPrice) maxPrice = price;
double fallFromPeak = (maxPrice - price) / maxPrice * 100;
if (fallFromPeak >= 1.0) emitSell(price); // 1% drop from peak

Simpler. Good for faster-moving scalps.

EMA gain reset

// In onChange:
if (exitEmaGain.getPeriodCount() == 0) emitSell(price);

Sell when the exit EMA stops rising (momentum exhausted). Requires a GainRTIndicator wrapping the exit EMA.


Conditional stop-loss

A macro-aware stop-loss that avoids stopping out during healthy dips:

// In onChange:
double percentGain = (price - buyPrice) / buyPrice * 100;
if (percentGain < 0
        && Math.abs(percentGain) >= 0.5           // loss > 0.5%
        && longDistemas < 0.01) {                  // macro trend weakening
    emitSell(price);
    store.set("fail");
}

Only triggers when the macro context is also weak. Reduces false stops in volatile but ultimately trending markets.


Re-entry protection (“fail” state)

Block new entries after a losing trade until the macro context fully resets:

// Entry listener:
if (store.is("fail")) return;  // block until macro reset
// ...entry conditions...

// Separate macro reset check (in another window or update):
if (store.is("fail") && longEmaValue < vlongEmaValue) {
    store.unset("fail");
}

Prevents revenge trading after a stop-loss. The reset condition (longEma < vlongEma) ensures a full macro cycle completes before re-entry is allowed.


OPS-based instrument activity

Track operations/second per instrument to identify dormant coins waking up:

// In update():
long now = System.currentTimeMillis();
long currentOps = opsCounter.incrementAndGet();
if (now - lastOpsWindow > 1000) {
    double ops = (double) currentOps / ((now - lastOpsWindow) / 1000.0);
    lastOps = ops;
    lastOpsWindow = now;
    opsCounter.set(0);
}
// Low ops + sudden spike = pump candidate

Pattern: Sort all instruments by ops ascending. Coins with < 0.1 ops/sec that suddenly spike are pump candidates.


Multi-stage entry filter

All production-tested strategies use 2–3 independent confirmation stages before entry:

StrategyStage 1Stage 2Stage 3
S1BB bandwidth in [0.5, 0.6]Price above EMA500
S2EMA gain streak >= 3Price above EMA200Volatility >= 50%
S3VlongEMA streak >= 300Distance rising for 10+ ticksDistance >= 0.35%

Rule of thumb: At least one momentum condition + one macro trend condition + one noise/false-positive guard.


Shared state between windows

Every window built on the same InstrumentGroupRTIndicator shares one instrument-level StateStore — no wiring needed. An entry window can set a flag and an exit window on the same group reads it straight away, because both onChange calls receive the same store:

indicators
    .addPrice()
    .ema("ema10", 10)
    .window("ema10", WindowTime.s1, new EntryListener(this, indicators))
    .window("price", Duration.ofMillis(100), new ExitListener(this, indicators));

// EntryListener.onChange(StateStore store, ...) { store.set("inPosition"); ... }
// ExitListener.onChange(StateStore store, ...)  { if (store.is("inPosition")) ... }

Volatility gate

Prevent entries during low-activity periods:

indicators
    .addPrice()
    .add("vlts", new VolatilityRTIndicator(smaPeriods).clampUpdates(warmupPeriods))
    // ...other indicators...

// In entry listener:
double volatility = indicators.getValue("vlts");
if (volatility < 50.0) return; // market not active enough

clampUpdates(n) suppresses the first N updates (returns 0) to allow the underlying SMA to warm up before volatility values are meaningful.


Cross-instrument (market-wide) strategies

A strategy instance sees every accepted instrument, each with its own indicator group. To compute something across instruments (a market-wide percentile, a relative-strength rank, a basket signal), override update(Ticker), call super.update(ticker) first so the engine advances the firing instrument’s indicators, then read any instrument’s indicators:

@Override
public void update(Ticker ticker) {
    super.update(ticker);                       // engine updates THIS instrument's group
    Instrument ins = ticker.instrument();

    double z = getRTIndicator(ins, "closeZScore")     // this instrument's own indicator
        .map(RTIndicator::getValue).orElse(Double.NaN);

    List<Double> prices = new ArrayList<>();          // read across all tracked instruments
    for (Instrument other : getInstruments()) {
        getRTIndicator(other, "price")
            .filter(RTIndicator::isReady)
            .ifPresent(ind -> prices.add(ind.getValue()));
    }
    // ... compute a market-wide stat from `prices`, then emitSignal(...)
}

Helpers on AbstractTickerStrategy:

  • getInstruments() — the set of instruments the strategy is tracking.
  • getRTIndicator(instrument, name)Optional<RTIndicator> — any instrument’s named indicator (read-only, no re-update).
  • Always call super.update(ticker) first — skip it and the indicators never advance.