Trend-follow — EMA crossover¶
The simplest momentum strategy: buy when a fast moving average crosses above a slower one, exit on the reverse cross. With a fixed bracket so a bad trade doesn't run.
What it does¶
- Trades Bitcoin on 1-minute candles
- Buys when EMA(9) crosses above EMA(21) — momentum has shifted up
- Sells when EMA(9) crosses below EMA(21) — momentum has shifted down
- Bracket caps each trade at a 50% loss / 100% gain (relative to the position's stop distance, not the position value)
- One position at a time — won't pyramid into the trade
This is the textbook trend-follower. It catches sustained moves; it underperforms in choppy markets where the EMAs whipsaw.
The strategy file¶
STRATEGY trend_follow VERSION 1
LET fast = 9
LET slow = 21
SYMBOLS
btc = BACKTEST:BTCUSDT EVERY 1m
RULES
WHEN ema(btc.close, fast) CROSSES ABOVE ema(btc.close, slow)
THEN BUY btc SIZING 0.1
BRACKET {
STOP_LOSS BY 50 PCT,
TAKE_PROFIT BY 100 PCT
}
LOG "long entry" fast=ema(btc.close, fast) slow=ema(btc.close, slow)
WHEN ema(btc.close, fast) CROSSES BELOW ema(btc.close, slow)
THEN CLOSE btc
LOG "exit on cross-below"
How to run it¶
# Fetch a month of Bitcoin data if you don't have it
./scripts/fetch-dukascopy.sh BTCUSDT 2024-01-01 2024-02-01
# Backtest
qkt backtest strategies/trend-follow.qkt --from 2024-01-01 --to 2024-02-01
# Paper-trade
qkt run strategies/trend-follow.qkt
What to expect¶
A typical month of BTC at 1-minute resolution produces 8–15 trades with the default (9, 21) parameters. Win rate hovers around 40–50% — trend-following strategies make money by letting winners run, not by winning often.
If your numbers are wildly different, check the date range and that the data populated correctly (./scripts/fetch-dukascopy.sh BTCUSDT ... writes to ~/.qkt/data/symbols/BTCUSDT/).
How to adapt it¶
Different fast/slow periods¶
Edit the LET lines:
Or sweep them — see Run a parameter sweep.
Different timeframe¶
Change EVERY 1m to EVERY 5m, EVERY 15m, EVERY 1h, etc. 1m generates noise; 1h smooths it. Most traders find 15m–1h is the sweet spot for crypto trend-follow.
Different asset¶
Swap the symbol declaration:
Then reference eur instead of btc in the rules. The strategy logic is symbol-agnostic.
Different bracket¶
The 50 PCT / 100 PCT is percent-of-entry-price, not percent-of-position. To set absolute prices instead:
BRACKET {
STOP_LOSS AT btc.close * 0.95, -- 5% below entry
TAKE_PROFIT AT btc.close * 1.10 -- 10% above entry
}
Add a volatility filter¶
Trend-followers do badly in choppy ranges. Gate entries by ATR:
LET vol = atr(btc, 14)
RULES
WHEN ema(btc.close, fast) CROSSES ABOVE ema(btc.close, slow)
AND vol > 50 -- only enter when there's enough movement
THEN BUY btc SIZING 0.1 BRACKET { STOP_LOSS BY 50 PCT, TAKE_PROFIT BY 100 PCT }
Common gotchas¶
- First-bar bug. If your start date happens to be already in an uptrend (EMA9 > EMA21 on bar 1), the rule won't fire until the next cross. This is correct edge-trigger behaviour, but surprises people. To trade from the start, add a different entry rule or backfill a few days of warmup.
- EMAs need warmup. The first ~21 bars produce null EMA values; rules using them won't fire. The compiler infers warmup automatically from the
21inema(..., 21). (An explicitWARMUP N BARSdeclaration is on the roadmap.) - Bracket fills aren't free. Even though they appear atomic in the DSL, the broker may fill the stop with slippage on a fast move. Paper-test for at least a few weeks before live deployment.
What this example demonstrates¶
- Single stream declaration (
btc = BACKTEST:BTCUSDT EVERY 1m) - Indicator function calls (
ema(btc.close, 9)) - Edge-triggered conditions (
CROSSES ABOVE) - Atomic bracket orders
- LOG actions for inline debugging
LETvariables for tunable parameters
See the DSL reference for everything else.