多空皆可的 Pull back (1h + 4h)策略: Long 進場(下一根)需同時滿足: 1) 4h RSI > threshold 2) 1h ADX > threshold 3) 4h ADX > threshold 4) 1h 收盤 < BB 下軌 Short 進場(下一根)需同時滿足: 1) 4h RSI < threshold 2) 1h ADX > threshold 3) 4h ADX > threshold 4) 1h 收盤 > BB 上軌
Timeframe
1h
Direction
Long & Short
Stoploss
-99.0%
Trailing Stop
No
ROI
0m: 100.0%
Interface Version
N/A
Startup Candles
200
Indicators
4
freqtrade/freqtrade-strategies
Strategy 003 author@: Gerald Lonlas github@: https://github.com/freqtrade/freqtrade-strategies
# pragma: no cover
from typing import Dict, Tuple
from datetime import datetime
import numpy as np
import pandas as pd
from pandas import DataFrame
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, RealParameter
from freqtrade.persistence import Trade
import talib
class Pull_Back_Slower(IStrategy):
"""
多空皆可的 Pull back (1h + 4h)策略:
Long 進場(下一根)需同時滿足:
1) 4h RSI > threshold
2) 1h ADX > threshold
3) 4h ADX > threshold
4) 1h 收盤 < BB 下軌
Short 進場(下一根)需同時滿足:
1) 4h RSI < threshold
2) 1h ADX > threshold
3) 4h ADX > threshold
4) 1h 收盤 > BB 上軌
出場:
Long:1h 收盤 > BB 上軌
Short:1h 收盤 < BB 下軌
自訂停損:以「訊號K」的價格與 ATR 決定,並在開倉後固定(不追蹤)。
倉位 sizing:根據 ATR 停損距離與單筆風險比例(risk_per_trade)動態計算。
"""
# --- 基本設定 ---
timeframe = "1h"
informative_timeframe = "4h"
can_short = True
minimal_roi = {"0": 1.0} # 不使用 ROI 強制出場(全靠 exit_signal / stoploss)
stoploss = -0.99 # 全域保險底線,實際用 custom_stoploss
use_custom_stoploss = True
process_only_new_candles = True
startup_candle_count = 200
# --- 超參數(可 hyperopt)---
bb_period = IntParameter(10, 60, default=20, space="buy", optimize=True)
bb_stds = RealParameter(1.0, 3.5, step=0.1, default=2.0, space="buy", optimize=True)
htf_rsi_threshold = IntParameter(30, 70, default=45, space="buy", optimize=True) # 4h RSI 閾值
htf_adx_threshold = IntParameter(15, 40, default=25, space="buy", optimize=True) # 4h ADX 閾值
adx_threshold = IntParameter(10, 40, default=20, space="buy", optimize=True) # 1h ADX 閾值
atr_mult = RealParameter(2.0, 6.0, default=4.5, step=0.1, space="sell", optimize=True)
# risk_per_trade_hp = RealParameter(0.002, 0.02, default=0.01, step=0.005, space="sell", optimize=True)
# 固定視窗
adx_window = 14
atr_window = 14
# 風險參數(單筆最大風險佔用戶總資金比例)
risk_per_trade: float = 0.01
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 用來存每筆 trade 的固定停損比例
self._sl_cache = {}
# --------- 工具函式(計算指標)---------
@staticmethod
def _add_bbands(df: DataFrame, period: int, stdevs: float, prefix: str = "") -> DataFrame:
upper, middle, lower = talib.BBANDS(
df["close"], timeperiod=period, nbdevup=stdevs, nbdevdn=stdevs, matype=0
)
df[f"{prefix}bb_up"] = upper
df[f"{prefix}bb_mid"] = middle
df[f"{prefix}bb_low"] = lower
return df
@staticmethod
def _add_adx(df: DataFrame, window: int, prefix: str = "") -> DataFrame:
df[f"{prefix}adx"] = talib.ADX(df["high"], df["low"], df["close"], timeperiod=window)
return df
@staticmethod
def _add_atr(df: DataFrame, window: int, prefix: str = "") -> DataFrame:
df[f"{prefix}atr"] = talib.ATR(df["high"], df["low"], df["close"], timeperiod=window)
return df
@staticmethod
def _add_rsi(df: DataFrame, window: int, colname: str = "rsi") -> DataFrame:
df[colname] = talib.RSI(df["close"], timeperiod=window)
return df
# --------- 將 4h 指標合併到 1h ---------
@staticmethod
def _merge_4h_into_1h(df_1h: DataFrame, df_4h: DataFrame) -> DataFrame:
"""
將 4h 的 rsi_4h / adx_4h 透過時間對齊 forward-fill 進 1h dataframe。
支援 'date' 欄或 datetime index 兩種格式。
"""
inf = df_4h.copy()
if "date" not in inf.columns:
inf["date"] = inf.index
inf_small = inf[["date", "rsi_4h", "adx_4h"]].dropna(how="all")
if "date" in df_1h.columns:
one_sorted = df_1h.sort_values(by="date")
inf_sorted = inf_small.sort_values(by="date")
merged = pd.merge_asof(one_sorted, inf_sorted, on="date", direction="backward")
merged = merged.ffill().sort_index()
return merged
else:
out = df_1h.sort_index().copy()
inf2 = inf_small.set_index("date").sort_index()
out[["rsi_4h", "adx_4h"]] = inf2[["rsi_4h", "adx_4h"]].reindex(out.index, method="ffill")
return out
def informative_pairs(self):
return [(pair, self.informative_timeframe) for pair in self.dp.current_whitelist()]
def merge_informative(self, df: DataFrame, metadata: Dict) -> DataFrame:
pair = metadata["pair"]
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=self.informative_timeframe)
# 計算 4h 指標
inf = self._add_rsi(inf, 14, colname="rsi_4h")
inf = self._add_adx(inf, self.adx_window, prefix="")
inf.rename(columns={"adx": "adx_4h"}, inplace=True)
# 合併到 1h
df = self._merge_4h_into_1h(df, inf)
return df
# --------- 指標計算 ---------
def populate_indicators(self, df: DataFrame, metadata: Dict) -> DataFrame:
df = self.merge_informative(df, metadata)
df = self._add_bbands(df, period=int(self.bb_period.value), stdevs=float(self.bb_stds.value))
df = self._add_adx(df, window=self.adx_window)
df = self._add_atr(df, window=self.atr_window)
df["volume_ok"] = df["volume"] > 0
return df
# --------- 進出場條件 ---------
def _long_entry_condition(self, df: DataFrame) -> pd.Series:
return (
(df["rsi_4h"] > int(self.htf_rsi_threshold.value)) &
(df["adx"] > int(self.adx_threshold.value)) &
(df["adx_4h"] > int(self.htf_adx_threshold.value)) &
(df["close"] < df["bb_low"]) &
(df["volume_ok"])
)
def _short_entry_condition(self, df: DataFrame) -> pd.Series:
return (
(df["rsi_4h"] < int(self.htf_rsi_threshold.value)) &
(df["adx"] > int(self.adx_threshold.value)) &
(df["adx_4h"] > int(self.htf_adx_threshold.value)) &
(df["close"] > df["bb_up"]) &
(df["volume_ok"])
)
def populate_entry_trend(self, df: DataFrame, metadata: Dict) -> DataFrame:
df.loc[self._long_entry_condition(df), ["enter_long", "enter_tag"]] = (1, "rsi4h_adx_bb_long")
df.loc[self._short_entry_condition(df), ["enter_short", "enter_tag"]] = (1, "rsi4h_adx_bb_short")
return df
def populate_exit_trend(self, df: DataFrame, metadata: Dict) -> DataFrame:
df.loc[(df["close"] > df["bb_up"]), "exit_long"] = 1
df.loc[(df["close"] < df["bb_low"]), "exit_short"] = 1
return df
# ===============================
# 倉位計算小工具(根據停損距離)
# ===============================
def position_size(self, balance: float, entry_price: float, stop_price: float, risk_per_trade: float = 0.01) -> float:
"""
根據停損距離 & 風險承受度,自動計算倉位大小。
回傳值:應投入的 USDT 數量
"""
max_loss = balance * risk_per_trade # 允許的最大虧損(USDT)
risk_per_unit = abs(entry_price - stop_price) # 每單位風險(USDT)
if risk_per_unit <= 0:
return 0.0
position_units = max_loss / risk_per_unit
stake_amount = position_units * entry_price
return float(stake_amount)
# ===============================
# Freqtrade callback: 自訂倉位大小
# ===============================
def custom_stake_amount(
self,
pair: str,
current_time: datetime,
current_rate: float,
proposed_stake: float,
min_stake: float,
max_stake: float,
**kwargs,
) -> float:
"""
動態計算倉位大小:
- 以 ATR(1h) * atr_mult 當作停損距離
- 單筆風險 = risk_per_trade(預設 1%)
"""
try:
balance = float(self.wallets.get_total_stake_balance())
except Exception:
# 回退:若無法讀取錢包(回測理論上可以),用 10000 模擬
balance = 10000.0
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is None or df.empty or "atr" not in df.columns:
# 沒資料就使用系統建議
stake = proposed_stake
else:
atr = float(df["atr"].iloc[-1])
stop_price = current_rate - float(self.atr_mult.value) * atr
# risk = float(self.risk_per_trade_hp.value)
risk = self.risk_per_trade
stake = self.position_size(
balance=balance,
entry_price=current_rate,
stop_price=stop_price,
risk_per_trade=risk,
)
# 夾在交易所允許範圍內
if max_stake is not None:
stake = min(stake, max_stake)
if min_stake is not None:
stake = max(stake, min_stake)
return float(stake)
# ===============================
# 自訂停損(固定,不追蹤)
# ===============================
def custom_stoploss(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs,
) -> float:
"""
以「訊號K」計算固定相對停損:
Long: stop = 訊號收盤 - atr_mult * ATR(1h)
Short: stop = 訊號最高 + atr_mult * ATR(1h)
回傳 *相對損失比*(負值)。首次計算後鎖定於 _sl_cache, 之後不再更新。
"""
# 若已計算,直接回傳
if trade.id in self._sl_cache:
return self._sl_cache[trade.id]
# 取得 dataframe
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is None or df.empty or "atr" not in df.columns:
return 1
# 找到訊號K位置
if "date" in df.columns:
idx = df.index[df["date"] >= trade.open_date_utc]
else:
idx = df.index[df.index >= trade.open_date_utc]
if len(idx) == 0:
return 1
entry_loc = idx[0]
try:
sig_pos = df.index.get_loc(entry_loc) - 1
if sig_pos < 0:
return 1
except Exception:
return 1
row_sig = df.iloc[sig_pos]
atr_mult = float(self.atr_mult.value)
# 判斷方向
direction = getattr(trade, "direction", None)
if direction is None:
direction = "short" if getattr(trade, "is_short", False) else "long"
entry_price = float(trade.open_rate)
if direction == "long":
stop_price = float(row_sig["close"]) - atr_mult * float(row_sig["atr"])
rel_sl = (stop_price - entry_price) / entry_price
else:
stop_price = float(row_sig["high"]) + atr_mult * float(row_sig["atr"])
rel_sl = (entry_price - stop_price) / entry_price
# 停損比例需為負值
rel_sl = min(rel_sl, -0.001)
# 鎖定在內部 cache,而不是 trade.user_data
self._sl_cache[trade.id] = rel_sl
return rel_sl
# 槓桿(若使用合約)
def leverage(self, *args, **kwargs) -> float:
return 3.0
# 繪圖設定 (Freqtrade plot-dataframe 用)
plot_config = {
"main_plot": {
# K 線上畫的
"bb_up": {"color": "red"},
"bb_mid": {"color": "orange"},
"bb_low": {"color": "green"},
},
"subplots": {
# 額外開小圖
"RSI (4h)": {
"rsi_4h": {"color": "blue"},
},
"ADX (1h)": {
"adx": {"color": "purple"},
},
"ADX (4h)": {
"adx_4h": {"color": "pink"},
},
"ATR (1h)": {
"atr": {"color": "black"},
},
}
}
# --- Strategy idea ---
# https://www.youtube.com/watch?v=c9-SIpy3dEw