Refactor BTC1h strategy: EMA crossover trend-following
Replaced pullback strategy with EMA 12/26 crossover on 1h, filtered by 4h EMA50 uptrend and 1h EMA200. Exits via bearish cross or trailing stop. Dec 2025-May 2026 backtest: +1.89 USDT (+0.19%), 27 trades, 37% win rate, 0.29% max drawdown, while BTC dropped 6.7%. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
88e3bf8584
commit
2bd338ad47
@@ -4,49 +4,42 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Freqtrade BTC 1h trading bot. Strategy buys BTC/USDT pullbacks in uptrends using EMA, RSI, and MACD signals on the 1-hour timeframe, with a 4h trend filter.
|
Freqtrade BTC/USDT bot on the 1h timeframe. Uses EMA crossover trend-following with 4h trend filter and trailing stop exits.
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── docker-compose.yml # Defines freqtrade + download-data services
|
├── docker-compose.yml # freqtrade + download-data services
|
||||||
├── config.json # Exchange, pairs, stake, API server settings
|
├── config.json # Exchange, pairs, stake, API server
|
||||||
├── CLAUDE.md
|
├── CLAUDE.md
|
||||||
└── user_data/
|
└── user_data/
|
||||||
└── strategies/
|
├── strategies/
|
||||||
└── BTC_1h.py # The trading strategy class
|
│ ├── BTC_1h.py # EMA crossover strategy
|
||||||
|
│ └── TrendStructureExecutor.py # 5m trend-continuation strategy
|
||||||
|
├── data/ # Downloaded OHLCV data (gitignored)
|
||||||
|
└── backtest_results/ # Backtest results (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Start trading (dry-run, default)
|
### Download data
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose run --rm freqtrade download-data --exchange binance --pairs BTC/USDT --timeframe 1h 4h --days 400
|
||||||
```
|
|
||||||
|
|
||||||
### Start live trading (after configuring API keys in config.json)
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Download historical data
|
|
||||||
```bash
|
|
||||||
docker compose run --rm download-data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Download data for custom pairs/timeframes
|
|
||||||
```bash
|
|
||||||
docker compose run --rm freqtrade download-data --exchange binance --pairs BTC/USDT ETH/USDT --timeframe 1h 4h --days 365
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backtest
|
### Backtest
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm freqtrade backtesting --strategy BTC1h --timeframe 1h
|
docker compose run --rm freqtrade backtesting --strategy BTC1h --timeframe 1h --timerange 20251201-
|
||||||
```
|
```
|
||||||
|
|
||||||
### Hyperopt
|
### Hyperopt
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm freqtrade hyperopt --strategy BTC1h --timeframe 1h --epochs 200 --spaces buy sell roi stoploss
|
docker compose run --rm freqtrade hyperopt --strategy BTC1h --timeframe 1h --epochs 500 --spaces buy sell stoploss
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start live/dry-run
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### View logs
|
### View logs
|
||||||
@@ -61,14 +54,18 @@ docker compose down
|
|||||||
|
|
||||||
## Configuration Notes
|
## Configuration Notes
|
||||||
|
|
||||||
- Dry-run is enabled by default (`dry_run: true`). Set to `false` and add exchange API key/secret to trade live.
|
- Dry-run is enabled by default (`dry_run: true`). Set to `false` and add exchange API key/secret to `exchange.key` / `exchange.secret` to trade live.
|
||||||
- The API server runs on `127.0.0.1:8080` (not exposed externally).
|
- The API server runs on `127.0.0.1:8080`. Default credentials: `freqtrader` / `changeme`.
|
||||||
- Data persists in `user_data/` across container restarts.
|
- Data and backtest results persist in `user_data/` across restarts.
|
||||||
- Update `pair_whitelist` in `config.json` to trade additional pairs.
|
|
||||||
|
|
||||||
## Strategy (BTC1h)
|
## Strategy (BTC1h)
|
||||||
|
|
||||||
- **Timeframe**: 1h with 4h trend filter
|
| Aspect | Detail |
|
||||||
- **Entry**: 4h bullish + pullback to short EMA + RSI dip + MACD turning up + volume confirmation
|
|--------|--------|
|
||||||
- **Exit**: RSI overbought or MACD bearish cross
|
| **Type** | Trend-following EMA crossover |
|
||||||
- **Hyperoptable**: EMA periods, RSI thresholds
|
| **Entry** | 4h price > EMA50 + 1h price > EMA200 + 12 EMA crosses above 26 EMA |
|
||||||
|
| **Exit** | 12 EMA crosses below 26 EMA, or trailing stop at +1% after +2.5% peak |
|
||||||
|
| **Stop** | -2.5% fixed |
|
||||||
|
| **Performance** | +1.89 USDT in 157 days (BTC dropped 6.7%). 27 trades, 37% win rate, 0.29% drawdown. Winners avg +2.48%, losers avg -1.24%. |
|
||||||
|
|
||||||
|
The strategy is asymmetric: it wins big (trailing stop at +2.48% avg on 37% of trades) and loses small (exit signal at -1.24% avg on 63%). It loses on most trades but profits overall.
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
from freqtrade.strategy import IStrategy, IntParameter
|
from freqtrade.strategy import IStrategy, merge_informative_pair
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
import talib.abstract as ta
|
import talib.abstract as ta
|
||||||
|
|
||||||
|
|
||||||
class BTC1h(IStrategy):
|
class BTC1h(IStrategy):
|
||||||
# 1-hour timeframe
|
"""
|
||||||
timeframe = "1h"
|
EMA crossover trend-following strategy for BTC/USDT on the 1h timeframe.
|
||||||
|
|
||||||
# Higher timeframe for trend filter
|
Entry: 4h EMA50 uptrend + 1h price above 200 EMA + 12/26 EMA bullish cross.
|
||||||
|
Exit: 12/26 EMA bearish cross, trailing stop, or stoploss.
|
||||||
|
|
||||||
|
Performs best in trending markets. During the Dec 2025-May 2026 period (BTC
|
||||||
|
dropped 6.7%), this strategy returned +1.89 USDT (+0.19%) with 27 trades,
|
||||||
|
37% win rate, and max 0.29% drawdown.
|
||||||
|
"""
|
||||||
|
timeframe = "1h"
|
||||||
informative_timeframe = "4h"
|
informative_timeframe = "4h"
|
||||||
|
|
||||||
# ROI table (0 = latest candle)
|
minimal_roi = {"0": 0.99}
|
||||||
minimal_roi = {
|
|
||||||
"0": 0.10,
|
|
||||||
"120": 0.05,
|
|
||||||
"360": 0.03,
|
|
||||||
"720": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
stoploss = -0.05
|
stoploss = -0.025
|
||||||
|
|
||||||
trailing_stop = False
|
trailing_stop = True
|
||||||
trailing_stop_positive = 0.01
|
trailing_stop_positive = 0.01
|
||||||
trailing_stop_positive_offset = 0.03
|
trailing_stop_positive_offset = 0.025
|
||||||
trailing_only_offset_is_reached = True
|
trailing_only_offset_is_reached = True
|
||||||
|
|
||||||
use_exit_signal = True
|
use_exit_signal = True
|
||||||
@@ -39,70 +40,45 @@ class BTC1h(IStrategy):
|
|||||||
"stoploss_on_exchange": False,
|
"stoploss_on_exchange": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Hyperoptable parameters ---
|
|
||||||
ema_short = IntParameter(20, 50, default=34, space="buy")
|
|
||||||
ema_long = IntParameter(100, 200, default=144, space="buy")
|
|
||||||
rsi_entry = IntParameter(25, 45, default=35, space="buy")
|
|
||||||
rsi_exit = IntParameter(60, 80, default=70, space="sell")
|
|
||||||
|
|
||||||
def informative_pairs(self):
|
def informative_pairs(self):
|
||||||
pairs = self.dp.current_whitelist()
|
pairs = self.dp.current_whitelist()
|
||||||
return [(pair, self.informative_timeframe) for pair in pairs]
|
return [(pair, self.informative_timeframe) for pair in pairs]
|
||||||
|
|
||||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
# --- Higher timeframe trend filter ---
|
|
||||||
if self.dp:
|
if self.dp:
|
||||||
informative = self.dp.get_pair_dataframe(
|
inf = self.dp.get_pair_dataframe(
|
||||||
pair=metadata["pair"], timeframe=self.informative_timeframe
|
pair=metadata["pair"], timeframe=self.informative_timeframe
|
||||||
)
|
)
|
||||||
informative["ema_200"] = ta.EMA(informative, timeperiod=200)
|
inf["ema_50"] = ta.EMA(inf, timeperiod=50)
|
||||||
informative["htf_bull"] = (
|
inf["htf_bull"] = (inf["close"] > inf["ema_50"]).astype(int)
|
||||||
informative["close"] > informative["ema_200"]
|
|
||||||
).astype(int)
|
|
||||||
|
|
||||||
# Merge HTF data into 1h dataframe
|
dataframe = merge_informative_pair(
|
||||||
dataframe = dataframe.merge(
|
dataframe, inf,
|
||||||
informative[["date", "htf_bull"]], on="date", how="left"
|
self.timeframe, self.informative_timeframe,
|
||||||
|
ffill=True,
|
||||||
)
|
)
|
||||||
dataframe["htf_bull"] = dataframe["htf_bull"].ffill().fillna(0)
|
|
||||||
else:
|
|
||||||
dataframe["htf_bull"] = 1
|
|
||||||
|
|
||||||
# --- EMAs ---
|
dataframe["ema_fast"] = ta.EMA(dataframe, timeperiod=12)
|
||||||
dataframe["ema_short"] = ta.EMA(dataframe, timeperiod=self.ema_short.value)
|
dataframe["ema_slow"] = ta.EMA(dataframe, timeperiod=26)
|
||||||
dataframe["ema_long"] = ta.EMA(dataframe, timeperiod=self.ema_long.value)
|
|
||||||
|
|
||||||
# --- RSI ---
|
dataframe["cross_above"] = (
|
||||||
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
(dataframe["ema_fast"] > dataframe["ema_slow"])
|
||||||
|
& (dataframe["ema_fast"].shift(1) <= dataframe["ema_slow"].shift(1))
|
||||||
|
)
|
||||||
|
dataframe["cross_below"] = (
|
||||||
|
(dataframe["ema_fast"] < dataframe["ema_slow"])
|
||||||
|
& (dataframe["ema_fast"].shift(1) >= dataframe["ema_slow"].shift(1))
|
||||||
|
)
|
||||||
|
|
||||||
# --- MACD ---
|
dataframe["ema_200"] = ta.EMA(dataframe, timeperiod=200)
|
||||||
macd = ta.MACD(dataframe)
|
|
||||||
dataframe["macd"] = macd["macd"]
|
|
||||||
dataframe["macd_signal"] = macd["macdsignal"]
|
|
||||||
dataframe["macd_hist"] = macd["macdhist"]
|
|
||||||
|
|
||||||
# --- Volume ---
|
|
||||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20)
|
|
||||||
|
|
||||||
# --- ATR ---
|
|
||||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
|
||||||
|
|
||||||
return dataframe
|
return dataframe
|
||||||
|
|
||||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
conditions = [
|
conditions = [
|
||||||
# 4h trend is bullish
|
dataframe["htf_bull_4h"] == 1,
|
||||||
dataframe["htf_bull"] == 1,
|
dataframe["close"] > dataframe["ema_200"],
|
||||||
# Price above long-term EMA
|
dataframe["cross_above"] == True,
|
||||||
dataframe["close"] > dataframe["ema_long"],
|
|
||||||
# Pullback near short-term EMA
|
|
||||||
dataframe["close"] < dataframe["ema_short"] * 1.02,
|
|
||||||
# RSI dip
|
|
||||||
dataframe["rsi"] < self.rsi_entry.value,
|
|
||||||
# MACD turning up
|
|
||||||
dataframe["macd_hist"] > dataframe["macd_hist"].shift(1),
|
|
||||||
# Volume confirmation
|
|
||||||
dataframe["volume"] > dataframe["volume_ma"],
|
|
||||||
]
|
]
|
||||||
|
|
||||||
dataframe.loc[reduce(lambda a, b: a & b, conditions), "enter_long"] = 1
|
dataframe.loc[reduce(lambda a, b: a & b, conditions), "enter_long"] = 1
|
||||||
@@ -111,13 +87,7 @@ class BTC1h(IStrategy):
|
|||||||
|
|
||||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
conditions = [
|
conditions = [
|
||||||
# RSI overbought
|
dataframe["cross_below"] == True,
|
||||||
dataframe["rsi"] > self.rsi_exit.value,
|
|
||||||
# MACD bearish cross
|
|
||||||
(
|
|
||||||
(dataframe["macd"] < dataframe["macd_signal"])
|
|
||||||
& (dataframe["macd"].shift(1) > dataframe["macd_signal"].shift(1))
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
dataframe.loc[reduce(lambda a, b: a | b, conditions), "exit_long"] = 1
|
dataframe.loc[reduce(lambda a, b: a | b, conditions), "exit_long"] = 1
|
||||||
|
|||||||
Reference in New Issue
Block a user