From 88e3bf858416f1fd7f18f619b21f3fcd533c4713 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Thu, 7 May 2026 14:38:00 +0800 Subject: [PATCH] Initial commit: Freqtrade BTC 1h trading bot Co-Authored-By: Claude Opus 4.7 --- .gitignore | 5 + CLAUDE.md | 74 +++++ config.json | 57 ++++ docker-compose.yml | 26 ++ user_data/strategies/BTC_1h.py | 125 ++++++++ .../strategies/TrendStructureExecutor.py | 276 ++++++++++++++++++ 6 files changed, 563 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 config.json create mode 100644 docker-compose.yml create mode 100644 user_data/strategies/BTC_1h.py create mode 100644 user_data/strategies/TrendStructureExecutor.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5c8f0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +user_data/backtest_results/ +user_data/data/ +*.feather +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be33772 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 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. + +## File Structure + +``` +├── docker-compose.yml # Defines freqtrade + download-data services +├── config.json # Exchange, pairs, stake, API server settings +├── CLAUDE.md +└── user_data/ + └── strategies/ + └── BTC_1h.py # The trading strategy class +``` + +## Commands + +### Start trading (dry-run, default) +```bash +docker compose up -d +``` + +### 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 +```bash +docker compose run --rm freqtrade backtesting --strategy BTC1h --timeframe 1h +``` + +### Hyperopt +```bash +docker compose run --rm freqtrade hyperopt --strategy BTC1h --timeframe 1h --epochs 200 --spaces buy sell roi stoploss +``` + +### View logs +```bash +docker compose logs -f +``` + +### Stop +```bash +docker compose down +``` + +## Configuration Notes + +- Dry-run is enabled by default (`dry_run: true`). Set to `false` and add exchange API key/secret to trade live. +- The API server runs on `127.0.0.1:8080` (not exposed externally). +- Data persists in `user_data/` across container restarts. +- Update `pair_whitelist` in `config.json` to trade additional pairs. + +## Strategy (BTC1h) + +- **Timeframe**: 1h with 4h trend filter +- **Entry**: 4h bullish + pullback to short EMA + RSI dip + MACD turning up + volume confirmation +- **Exit**: RSI overbought or MACD bearish cross +- **Hyperoptable**: EMA periods, RSI thresholds diff --git a/config.json b/config.json new file mode 100644 index 0000000..9f6f8ac --- /dev/null +++ b/config.json @@ -0,0 +1,57 @@ +{ + "max_open_trades": 3, + "stake_currency": "USDT", + "stake_amount": 50, + "tradable_balance_ratio": 0.99, + "dry_run": true, + "dry_run_wallet": 1000, + "cancel_timeout_on_new_position": true, + "timeframe": "1h", + "fiat_display_currency": "USD", + "trading_mode": "spot", + "margin_mode": "", + "exchange": { + "name": "binance", + "key": "", + "secret": "", + "ccxt_config": { + "rateLimit": 50 + }, + "pair_whitelist": [ + "BTC/USDT" + ], + "pair_blacklist": [] + }, + "pairlists": [ + {"method": "StaticPairList"} + ], + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0 + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "telegram": { + "enabled": false, + "chat_id": "", + "token": "" + }, + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080, + "username": "freqtrader", + "password": "changeme" + }, + "initial_state": "running", + "force_entry_enable": true, + "internals": { + "process_throttle_secs": 5 + }, + "bot_name": "btc_1h" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..61ba56f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + freqtrade: + image: freqtradeorg/freqtrade:stable + container_name: btc_1h + restart: unless-stopped + volumes: + - ./config.json:/freqtrade/config.json:ro + - ./user_data:/freqtrade/user_data + ports: + - "127.0.0.1:8080:8080" + command: > + trade + --db-url sqlite:////freqtrade/user_data/tradesv3.sqlite + + download-data: + image: freqtradeorg/freqtrade:stable + profiles: ["utils"] + volumes: + - ./config.json:/freqtrade/config.json:ro + - ./user_data:/freqtrade/user_data + command: > + download-data + --exchange binance + --pairs BTC/USDT + --timeframe 1h + --days 365 diff --git a/user_data/strategies/BTC_1h.py b/user_data/strategies/BTC_1h.py new file mode 100644 index 0000000..f9f2595 --- /dev/null +++ b/user_data/strategies/BTC_1h.py @@ -0,0 +1,125 @@ +from functools import reduce + +from freqtrade.strategy import IStrategy, IntParameter +from pandas import DataFrame +import talib.abstract as ta + + +class BTC1h(IStrategy): + # 1-hour timeframe + timeframe = "1h" + + # Higher timeframe for trend filter + informative_timeframe = "4h" + + # ROI table (0 = latest candle) + minimal_roi = { + "0": 0.10, + "120": 0.05, + "360": 0.03, + "720": 0, + } + + stoploss = -0.05 + + trailing_stop = False + trailing_stop_positive = 0.01 + trailing_stop_positive_offset = 0.03 + trailing_only_offset_is_reached = True + + use_exit_signal = True + exit_profit_only = False + + startup_candle_count = 200 + + order_types = { + "entry": "limit", + "exit": "limit", + "stoploss": "market", + "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): + pairs = self.dp.current_whitelist() + return [(pair, self.informative_timeframe) for pair in pairs] + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # --- Higher timeframe trend filter --- + if self.dp: + informative = self.dp.get_pair_dataframe( + pair=metadata["pair"], timeframe=self.informative_timeframe + ) + informative["ema_200"] = ta.EMA(informative, timeperiod=200) + informative["htf_bull"] = ( + informative["close"] > informative["ema_200"] + ).astype(int) + + # Merge HTF data into 1h dataframe + dataframe = dataframe.merge( + informative[["date", "htf_bull"]], on="date", how="left" + ) + dataframe["htf_bull"] = dataframe["htf_bull"].ffill().fillna(0) + else: + dataframe["htf_bull"] = 1 + + # --- EMAs --- + dataframe["ema_short"] = ta.EMA(dataframe, timeperiod=self.ema_short.value) + dataframe["ema_long"] = ta.EMA(dataframe, timeperiod=self.ema_long.value) + + # --- RSI --- + dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) + + # --- MACD --- + 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 + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + conditions = [ + # 4h trend is bullish + dataframe["htf_bull"] == 1, + # Price above long-term EMA + 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 + + return dataframe + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + conditions = [ + # RSI overbought + 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 + + return dataframe diff --git a/user_data/strategies/TrendStructureExecutor.py b/user_data/strategies/TrendStructureExecutor.py new file mode 100644 index 0000000..06e883c --- /dev/null +++ b/user_data/strategies/TrendStructureExecutor.py @@ -0,0 +1,276 @@ +from functools import reduce + +import talib.abstract as ta +from pandas import DataFrame +from freqtrade.strategy import IStrategy, merge_informative_pair +from freqtrade.persistence import Trade +from datetime import datetime + + +class TrendStructureExecutor(IStrategy): + """ + TrendStructureExecutor — Trend-continuation strategy (spot/futures). + + Core concept: + Identify established trends on the 1h chart (EMA52 + MACD + EMA200), + then trade 5m continuation entries when the MACD histogram pulls back + to zero and resumes in the trend direction. Skip low-volatility + ranging markets. Partial take-profit on momentum weakening. + """ + + INTERFACE_VERSION = 3 + + # ========================================================================= + # CONFIGURATION + # ========================================================================= + + timeframe = "5m" + informative_timeframe = "1h" + + # Futures support (long + short) + # Set can_short = True and switch config to futures mode (BTC/USDT:USDT) + # to enable short trading. + can_short = False + # trading_mode = "futures" + # margin_mode = "isolated" + + # Risk management — fixed 0.8% stoploss (tighter than the 1% ROI target) + stoploss = -0.008 + + # Trailing stop to protect profits + trailing_stop = True + trailing_stop_positive = 0.004 + trailing_stop_positive_offset = 0.012 + trailing_only_offset_is_reached = True + + # Position adjustment for partial take-profits + position_adjustment_enable = True + + # ROI disabled — exits managed by trailing stop + partial TP + EMA52 breach + minimal_roi = {"0": 0.99} + + # General settings + use_exit_signal = True + exit_profit_only = False + startup_candle_count = 200 + process_only_new_candles = True + + order_types = { + "entry": "limit", + "exit": "limit", + "stoploss": "market", + "stoploss_on_exchange": False, + } + + # ========================================================================= + # INFORMATIVE PAIRS + # ========================================================================= + + def informative_pairs(self): + pairs = self.dp.current_whitelist() + return [(pair, self.informative_timeframe) for pair in pairs] + + # ========================================================================= + # INDICATORS + # ========================================================================= + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + 1h: EMA52, EMA200, MACD, slope, range/consolidation, trend flags. + 5m: MACD, histogram direction helpers. + """ + if self.dp: + informative = self.dp.get_pair_dataframe( + pair=metadata["pair"], timeframe=self.informative_timeframe + ) + + # --- EMA 52 --- + informative["ema_52"] = ta.EMA(informative, timeperiod=52) + + # --- EMA 200 (super-trend filter) --- + informative["ema_200"] = ta.EMA(informative, timeperiod=200) + + # EMA 52 slope (3-period ROC for noise reduction) + informative["ema_52_slope"] = ( + informative["ema_52"] - informative["ema_52"].shift(3) + ) + + # --- MACD (12, 26, 9) --- + macd_1h = ta.MACD(informative) + informative["macd_hist_1h"] = macd_1h["macdhist"] + informative["macd_hist_1h_delta"] = ( + informative["macd_hist_1h"] - informative["macd_hist_1h"].shift(1) + ) + + # --- Range / consolidation filter --- + # If the 20-candle price range is less than 1.5 %, the market is + # considered to be ranging and no entries are allowed. + informative["range_high_20"] = informative["high"].rolling(20).max() + informative["range_low_20"] = informative["low"].rolling(20).min() + informative["range_pct"] = ( + (informative["range_high_20"] - informative["range_low_20"]) + / informative["range_low_20"] + ) + informative["is_ranging"] = (informative["range_pct"] < 0.015).astype(int) + + # --- LONG trend confirmation --- + # Price above EMA52 + EMA52 sloping up + MACD histogram positive + # + histogram not shrinking significantly (delta > -0.5 * rolling std) + informative["trend_bull"] = ( + (informative["close"] > informative["ema_52"]) + & (informative["close"] > informative["ema_200"]) + & (informative["ema_52_slope"] > 0) + & (informative["macd_hist_1h"] > 0) + & ( + informative["macd_hist_1h_delta"] + > -informative["macd_hist_1h"].rolling(20).std() * 0.5 + ) + ).astype(int) + + # --- SHORT trend confirmation --- + # Price below EMA52 + EMA52 sloping down + MACD histogram negative + # + histogram not expanding upward (delta < +0.5 * rolling std) + informative["trend_bear"] = ( + (informative["close"] < informative["ema_52"]) + & (informative["close"] < informative["ema_200"]) + & (informative["ema_52_slope"] < 0) + & (informative["macd_hist_1h"] < 0) + & ( + informative["macd_hist_1h_delta"] + < informative["macd_hist_1h"].rolling(20).std() * 0.5 + ) + ).astype(int) + + # Merge 1h → 5m (merge_informative_pair handles lookahead protection + # by shifting the higher-timeframe data by one candle) + dataframe = merge_informative_pair( + dataframe, + informative, + self.timeframe, + self.informative_timeframe, + ffill=True, + ) + + # --- 5m MACD --- + macd_5m = ta.MACD(dataframe) + dataframe["macd_hist_5m"] = macd_5m["macdhist"] + + # Direction helpers (avoids repeating shift logic in entry/exit methods) + dataframe["macd_hist_5m_up"] = ( + dataframe["macd_hist_5m"] > dataframe["macd_hist_5m"].shift(1) + ) + dataframe["macd_hist_5m_down"] = ( + dataframe["macd_hist_5m"] < dataframe["macd_hist_5m"].shift(1) + ) + + return dataframe + + # ========================================================================= + # ENTRY LOGIC + # ========================================================================= + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + LONG: 1h bullish + 5m MACD hist pullback-then-resumption + recent reset. + SHORT: 1h bearish + 5m MACD hist pullback-then-resumption + recent reset. + Both skip ranging markets. + """ + # Columns from merge_informative_pair carry the _1h suffix + trend_bull = dataframe["trend_bull_1h"] + trend_bear = dataframe["trend_bear_1h"] + is_ranging = dataframe["is_ranging_1h"] + + # ── LONG ────────────────────────────────────────────────────────────── + + long_conditions = [ + trend_bull == 1, + is_ranging == 0, + dataframe["macd_hist_5m_down"].shift(1) == True, + dataframe["macd_hist_5m_up"] == True, + dataframe["macd_hist_5m"] > 0, + dataframe["macd_hist_5m"].rolling(3).min() < 0, + ] + + dataframe.loc[ + reduce(lambda a, b: a & b, long_conditions), + ["enter_long", "enter_tag"], + ] = (1, "long_continuation") + + # ── SHORT ───────────────────────────────────────────────────────────── + + short_conditions = [ + trend_bear == 1, + is_ranging == 0, + dataframe["macd_hist_5m_up"].shift(1) == True, + dataframe["macd_hist_5m_down"] == True, + dataframe["macd_hist_5m"] < 0, + dataframe["macd_hist_5m"].rolling(3).max() > 0, + ] + + dataframe.loc[ + reduce(lambda a, b: a & b, short_conditions), + ["enter_short", "enter_tag"], + ] = (1, "short_continuation") + + return dataframe + + # ========================================================================= + # EXIT LOGIC + # ========================================================================= + + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + LONG: exit on 1h EMA52 breach (trend reversal). + SHORT (futures only): exit on 1h EMA52 breach. + """ + long_cond = dataframe["close"] < dataframe["ema_52_1h"] + dataframe.loc[long_cond, "exit_long"] = 1 + dataframe.loc[long_cond, "exit_tag"] = "long_exit" + + if self.can_short: + short_cond = dataframe["close"] > dataframe["ema_52_1h"] + dataframe.loc[short_cond, "exit_short"] = 1 + dataframe.loc[short_cond, "exit_tag"] = "short_exit" + + return dataframe + + # ========================================================================= + # POSITION ADJUSTMENT (Partial Take-Profit) + # ========================================================================= + + def adjust_trade_position(self, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, + min_stake: float | None, max_stake: float, + current_entry_rate: float, current_exit_rate: float, + current_entry_profit: float, current_exit_profit: float, + **kwargs) -> float | None: + """ + Sell 50% when MACD momentum weakens while in profit. + + Fires once per trade (guarded by filled_exits). Exits half the + position when the 5m MACD histogram starts declining toward zero + while we are still above +0.5% profit. + """ + if current_profit <= 0.005: + return None + + # Only one partial exit per trade + filled_exits = trade.select_filled_orders(trade.exit_side) + if filled_exits: + return None + + dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe) + if dataframe is None or len(dataframe) < 2: + return None + + last = dataframe.iloc[-1] + prev = dataframe.iloc[-2] + + if trade.is_short: + if last["macd_hist_5m"] < 0 and last["macd_hist_5m"] > prev["macd_hist_5m"]: + return -(trade.stake_amount / 2) + else: + if last["macd_hist_5m"] > 0 and last["macd_hist_5m"] < prev["macd_hist_5m"]: + return -(trade.stake_amount / 2) + + return None