Files
Chan/strategies/ChanLun_ETH_60.py
T
PorterandCursor 2c1232555e refactor: 缠论引擎迁入 chan/ 分层解耦,指标外置
将核心结构、指标与分析拆到 chan/{core,indicators,analysis,pipeline};
根目录保留兼容 shim;strategies 改为从 chan 包导入;买卖点经 bsp_macd 与 MACD 接合。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:47:13 +08:00

472 lines
20 KiB
Python

# --- Do not remove these libs ---
from statistics import median
from freqtrade.strategy import IStrategy, stoploss_from_absolute
import sys
import os
# 添加父目录到系统路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from chan.pipeline.ChanLun import ChanLun
from chan.analysis.ChanLun_Classifier import ChanLunClassifier
from chan.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX
from chan.analysis.ChanPY import ChanPY
# --------------------------------
from technical.util import resample_to_interval, resampled_merge
import talib.abstract as ta
from pandas import DataFrame
from datetime import datetime, timedelta
from freqtrade.persistence import Trade, Order
from typing import Optional
import logging
logger = logging.getLogger(__name__)
### Now you can use logger.info('asfd') to log
# freqtrade plot-dataframe --strategy ChanLun_ETH_60 --datadir user_data/data/binance -c ./user_data/ChanLun_ETH_60.json --timerange=20250309-
# freqtrade trade -c ./user_data/Chan/config/ChanLun_ETH_60.json --strategy ChanLun_ETH_60 --strategy-path ./user_data/Chan/strategies
# freqtrade backtesting -c ./user_data/Chan/config/ChanLun_ETH_60.json --strategy ChanLun_ETH_60 --strategy-path ./user_data/Chan/strategies --timerange=20250712-
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_ETH_60.json -t 1m --pairs ETH/USDT:USDT --timerange=20240101-
# freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi stoploss --strategy ChanLun_ETH_60 --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/ChanLun_ETH_60.json -e 200 --timerange=20250201-20250401
# sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/ChanLun_BTC_30.json --strategy ChanLun_BTC_30 --strategy-path ./user_data/Chan/strategies --timerange=20250721-
# sudo docker compose run --rm chanlun_btc download-data -c ./user_data/Chan/config/ChanLun_BTC_30.json --pairs BTC/USDT:USDT -t 1m --timerange 20240101-
# sudo docker compose run --rm chanlun_btc trade -c ./user_data/Chan/config/ChanLun_BTC_30.json --strategy ChanLun_BTC_30 --strategy-path ./user_data/Chan/strategies
class ChanLun_ETH_60(IStrategy):
INTERFACE_VERSION: int = 3
# Minimal ROI designed for the strategy.
# This attribute will be overridden if the config file contains "minimal_roi"
# 30m and 1h
minimal_roi = {
"0": 0.15,
"360": 0.2,
"640": 0.1,
"1200": 0
}
# 5m and 15m
minimal_roi_1 = {
"0": 0.1,
"60": 0.05,
"120": 0.02,
"240": 0
}
# 15m and 30m
minimal_roi_1 = {
"0": 0.1,
"240": 0.05,
"480": 0.03,
"600": 0
}
minimal_roi_1 = {
"0": 1.50,
"120": 0.05,
"240": 0.025,
"360": 0
}
minimal_roi = {
}
can_short = True
lev = 1.0
stoploss = -0.3 # 设置为很大的负值,让custom_stoploss来控制
use_custom_stoploss = False # 启用自定义止损
trailing_stop = False
trailing_stop_positive = 0.025
trailing_stop_positive_offset = 0.045
trailing_only_offset_is_reached = False
# 启用仓位调整功能以支持分批止盈
position_adjustment_enable = True
startup_candle_count = 2880
time5 = 5
time15 = 15
time30 = 30
time60 = 60
time4h = 240
time30 = 60
last_time = datetime.now()
chan = ChanLun()
chanpy = ChanPY()
classifier = ChanLunClassifier(None)
last_order = None
last_trade = None
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# resample our dataframes
dataframe_3 = resample_to_interval(dataframe, self.get_ticker_indicator() * 3)
dataframe_5 = resample_to_interval(dataframe, self.get_ticker_indicator() * 5)
dataframe_15 = resample_to_interval(dataframe, self.get_ticker_indicator() * 15)
dataframe_30 = resample_to_interval(dataframe, self.get_ticker_indicator() * 30)
dataframe_60 = resample_to_interval(dataframe, self.get_ticker_indicator() * 60)
dataframe_4h = resample_to_interval(dataframe, self.get_ticker_indicator() * 240)
#dataframe_1d = self.dp.get_pair_dataframe(pair=metadata['pair'], timeframe='1d')
#dataframe_1w = resample_to_interval(dataframe_1d, self.get_ticker_indicator() * 10080)
#dataframe_1m = resample_to_interval(dataframe_1d, self.get_ticker_indicator() * 43200)
dataframe_1d = resample_to_interval(dataframe, self.get_ticker_indicator() * 1440)
#dataframe_1w = resample_to_interval(dataframe, self.get_ticker_indicator() * 10080)
#dataframe_1m = resample_to_interval(dataframe, self.get_ticker_indicator() * 43200)
dataframe = self.add_indicators(dataframe)
dataframe_3 = self.add_indicators(dataframe_3)
dataframe_5 = self.add_indicators(dataframe_5)
dataframe_15 = self.add_indicators(dataframe_15)
dataframe_30 = self.add_indicators(dataframe_30)
dataframe_60 = self.add_indicators(dataframe_60)
dataframe_4h = self.add_indicators(dataframe_4h)
dataframe_1d = self.add_indicators(dataframe_1d)
#self.chan.plot_dual(dataframe_5, dataframe_30)
#chanpy_state = self.chanpy.get_bsp_state(dataframe_5)
#dataframe_5['chanpy_state'] = chanpy_state
state_list = self.chan.get_klc_state_list(dataframe_60)
dataframe_60['state'] = state_list
dataframe_60['fx'] = state_list
#bi_list_1 = self.chan.get_bi_list(dataframe)
#bi_list_5 = self.chan.get_bi_list(dataframe_5)
#bi_list_15 = self.chan.get_bi_list(dataframe_15)
#bi_list_30 = self.chan.get_bi_list(dataframe_30)
#bi_list_60 = self.chan.get_bi_list(dataframe_60)
if self.last_time + timedelta(minutes=1) < datetime.now():
#self.print_bi(bi_list_1)
#self.print_bi(bi_list_5)
#self.print_bi(bi_list_15)
#self.print_bi(bi_list_30)
#self.print_bi(bi_list_60)
self.print_seg(dataframe_5)
print("-------------------------------------------------------------------------------")
self.last_time = datetime.now()
dataframe = resampled_merge(dataframe, dataframe_3)
dataframe = resampled_merge(dataframe, dataframe_5)
#dataframe = resampled_merge(dataframe, dataframe_15)
#dataframe = resampled_merge(dataframe, dataframe_30)
dataframe = resampled_merge(dataframe, dataframe_60)
#dataframe = resampled_merge(dataframe, dataframe_4h)
return dataframe
def print_seg(self, dataframe):
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
seg_list = self.chan.get_seg_list(bi_list)
zs_list = self.chan.get_zs_list(bi_list, seg_list)
seg = seg_list[-1]
bi = bi_list[-1]
zs = zs_list[-1]
print(zs.start_time, zs.zg, zs.zd, zs.dir)
def print_bi(self, bi_list):
if bi_list and len(bi_list) > 2:
bi1 = bi_list[-1]
bi2 = bi_list[-2]
print(bi1.start_time, bi1.end_time, bi1.dir, bi2.start_time, bi2.end_time, bi2.dir)
def add_indicators(self, df):
fast = 8
slow = 16
period = 6
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
bb120 = ta.BBANDS(df, timeperiod=120, nbdevup=3.0, nbdevdn=3.0, matype=0)
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
# 计算布林带中轨(移动平均线)
bb30_middle = ta.SMA(df, timeperiod=90)
# 手动计算布林带 %B 指标 (BBP)
# %B = (Price - Lower Band) / (Upper Band - Lower Band)
bbp365 = (df['close'] - bb365['lowerband']) / (bb365['upperband'] - bb365['lowerband'])
bbp120 = (df['close'] - bb120['lowerband']) / (bb120['upperband'] - bb120['lowerband'])
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
df['atr'] = ta.ATR(df, timeperiod=14)
df['bbup365'] = bb365['upperband']
df['bblow365'] = bb365['lowerband']
df['bbp365'] = bbp365
df['bbup120'] = bb120['upperband']
df['bblow120'] = bb120['lowerband']
df['bbp120'] = bbp120
df['bbup30'] = bb30['upperband']
df['bblow30'] = bb30['lowerband']
df['bbmiddle30'] = bb30_middle # 添加bb30中轨
df['bbp30'] = bbp30
df['bbup302'] = bb302['upperband']
df['bblow302'] = bb302['lowerband']
df['bbp302'] = bbp302
df['macd'] = macd['macd']
df['macdsignal'] = macd['macdsignal']
df['macdhist'] = macd['macdhist']
df['ema5'] = ta.EMA(df, timeperiod=5)
df['ema10'] = ta.EMA(df, timeperiod=10)
df['ema26'] = ta.EMA(df, timeperiod=26)
df['ema52'] = ta.EMA(df, timeperiod=52)
df['rsi'] = ta.RSI(df, timeperiod=14)
df['volume_ratio'] = self.cal_volume_ratio(df)
return df
def cal_volume_ratio(self, dataframe, window=10):
df = dataframe.copy()
# 计算过去N根K线的平均成交量
df['avg_volume'] = df['volume'].rolling(window=window).mean()
# 计算量比
df['volume_ratio'] = df['volume'] / df['avg_volume']
# 填充缺失值(前N根K线)
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
return df['volume_ratio']
def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float,
entry_tag: str | None, side: str, **kwargs) -> float:
new_entryprice = proposed_rate
if trade:
if trade.is_short:
new_entryprice = proposed_rate - 50
else:
new_entryprice = proposed_rate + 50
return new_entryprice
def custom_exit_price(self, pair: str, trade: Trade,
current_time: datetime, proposed_rate: float,
current_profit: float, exit_tag: str | None, **kwargs) -> float:
new_exitprice = proposed_rate
if trade:
if trade.is_short:
new_exitprice = proposed_rate + 50
else:
new_exitprice = proposed_rate - 50
return new_exitprice
def adjust_trade_position1(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float,
min_stake: Optional[float], max_stake: float,
current_entry_rate: float, current_exit_rate: float,
current_entry_profit: float, current_exit_profit: float,
**kwargs) -> Optional[float]:
"""
基于布林带的分批止盈逻辑
"""
# 获取当前数据
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
if dataframe is None or len(dataframe) == 0:
return None
last_candle = dataframe.iloc[-1]
# 获取布林带数据
bb30_middle = last_candle['bbmiddle30']
bb30_upper = last_candle['bbup30']
bb30_lower = last_candle['bblow30']
bb302_upper = last_candle['bbup302']
bb302_lower = last_candle['bblow302']
# 获取交易的状态标记
first_tp_triggered = trade.get_custom_data(key="first_tp_triggered", default=False)
second_tp_triggered = trade.get_custom_data(key="second_tp_triggered", default=False)
if trade.is_short:
# 做空逻辑
if not first_tp_triggered and current_rate <= bb30_middle:
# 第一次止盈:价格跌到bb30中轨,止盈50%
logger.info(f"做空第一次止盈触发:价格{current_rate} <= BB30中轨{bb30_middle}")
trade.set_custom_data(key="first_tp_triggered", value=True)
trade.set_custom_data(key="new_stoploss", value=trade.open_rate) # 设置止损为开仓价
return -(trade.amount * 0.5) # 减少50%仓位
elif first_tp_triggered and not second_tp_triggered and current_rate <= bb302_lower:
# 第二次止盈:继续跌到bb302下轨,止盈剩余仓位的60%
logger.info(f"做空第二次止盈触发:价格{current_rate} <= BB302下轨{bb302_lower}")
trade.set_custom_data(key="second_tp_triggered", value=True)
trade.set_custom_data(key="new_stoploss", value=bb30_middle) # 移动止损到bb30中轨
remaining_amount = trade.amount * 0.5 # 剩余50%
return -(remaining_amount * 0.6) # 减少剩余仓位的60%
else:
# 做多逻辑
if not first_tp_triggered and current_rate >= bb30_middle:
# 第一次止盈:价格涨到bb30中轨,止盈50%
logger.info(f"做多第一次止盈触发:价格{current_rate} >= BB30中轨{bb30_middle}")
trade.set_custom_data(key="first_tp_triggered", value=True)
trade.set_custom_data(key="new_stoploss", value=trade.open_rate) # 设置止损为开仓价
return -(trade.amount * 0.5) # 减少50%仓位
elif first_tp_triggered and not second_tp_triggered and current_rate >= bb302_upper:
# 第二次止盈:继续涨到bb302上轨,止盈剩余仓位的60%
logger.info(f"做多第二次止盈触发:价格{current_rate} >= BB302上轨{bb302_upper}")
trade.set_custom_data(key="second_tp_triggered", value=True)
trade.set_custom_data(key="new_stoploss", value=bb30_middle) # 移动止损到bb30中轨
remaining_amount = trade.amount * 0.5 # 剩余50%
return -(remaining_amount * 0.6) # 减少剩余仓位的60%
return None
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool,
**kwargs) -> float | None:
"""
动态止损逻辑
"""
# 检查是否有自定义的新止损价格(分批止盈后的动态止损)
new_stoploss_price = trade.get_custom_data(key="new_stoploss")
if new_stoploss_price:
logger.info(f"使用动态止损价格: {new_stoploss_price}")
return stoploss_from_absolute(new_stoploss_price, current_rate, is_short=trade.is_short)
# 如果没有ATR数据,使用固定的5%止损作为备用
logger.warning(f"未找到开仓时ATR数据,使用默认5%止损")
return -0.05
def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, **kwargs):
"""
自定义退出逻辑 - 处理最终止盈条件
"""
# 获取当前数据
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if dataframe is None or len(dataframe) == 0:
return None
last_candle = dataframe.iloc[-1]
# 获取布林带数据
bb30_upper = last_candle['bbup30']
bb30_lower = last_candle['bblow30']
# 检查是否已经触发过前两次止盈
first_tp_triggered = trade.get_custom_data(key="first_tp_triggered", default=False)
second_tp_triggered = trade.get_custom_data(key="second_tp_triggered", default=False)
if trade.is_short:
# 做空:如果价格跌到bb30下轨,全部止盈
if first_tp_triggered and second_tp_triggered and current_rate <= bb30_lower:
logger.info(f"做空最终止盈触发:价格{current_rate} <= BB30下轨{bb30_lower}")
return "short_final_tp_bb30_lower"
else:
# 做多:如果价格涨到bb30上轨,全部止盈
if first_tp_triggered and second_tp_triggered and current_rate >= bb30_upper:
logger.info(f"做多最终止盈触发:价格{current_rate} >= BB30上轨{bb30_upper}")
return "long_final_tp_bb30_upper"
# 原有退出逻辑
if trade.is_short:
last_high = trade.get_custom_data(key="entry_candle_high")
if last_high and current_rate > last_high:
return "Relay Top FX exit"
else:
last_low = trade.get_custom_data(key="entry_candle_low")
if last_low and current_rate < last_low:
return "Relay Bottom FX exit"
return None
def confirm_trade_entry1(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, current_time: datetime, entry_tag: str | None,
side: str, **kwargs) -> bool:
if self.last_trade:
if self.last_trade.is_short:
if side == 'short':
if self.last_trade.open_date + timedelta(minutes=30) > current_time:
return False
else:
return True
else:
if side == 'long':
if self.last_trade.open_date + timedelta(minutes=30) > current_time:
return True
else:
return False
#if self.last_trade:
#print(self.last_trade.open_date, current_time, self.last_trade.open_date + timedelta(minutes=self.time5))
return True
def custom_stoploss1(self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool,
**kwargs) -> float | None:
last_high = trade.get_custom_data(key="entry_candle_high")
last_low = trade.get_custom_data(key="entry_candle_low")
# Convert absolute price to percentage relative to current_rate
if last_high:
return stoploss_from_absolute(last_high, current_rate, is_short=trade.is_short)
if last_low:
return stoploss_from_absolute(last_low, current_rate, is_short=trade.is_short)
# return maximum stoploss value, keeping current stoploss price unchanged
return None
def order_filled(self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs) -> None:
"""
Called right after an order fills.
Will be called for all order types (entry, exit, stoploss, position adjustment).
:param pair: Pair for trade
:param trade: trade object.
:param order: Order object.
:param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
"""
# Obtain pair dataframe (just to show how to access it)
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
last_candle = dataframe.iloc[-1].squeeze()
# 保存开仓时的ATR值用于止损计算
if (trade.nr_of_successful_entries == 1) and (order.ft_order_side == trade.entry_side):
entry_atr = last_candle['atr']
trade.set_custom_data(key="entry_atr", value=entry_atr)
logger.info(f"保存开仓时ATR值: {entry_atr}")
return None
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
state_str = 'resample_{}_state'.format(self.get_ticker_indicator()*self.time30)
fx_str = 'resample_{}_fx'.format(self.get_ticker_indicator()*self.time30)
#chanpy_state_str = 'resample_{}_chanpy_state'.format(self.get_ticker_indicator()*self.time5)
shift_time = self.time30
dataframe.loc[
(
(dataframe[state_str].shift(shift_time) == "-10")
#(dataframe['state'] == "-30")
#(dataframe[state_str].shift(shift_time) == "-10")
#(dataframe[fx_str].shift(shift_time) == -1)
#(dataframe[chanpy_state_str].shift(shift_time+30) == 1)
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time5)].shift(self.time5) == "-10") &
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time30)] == "-10") &
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time5)].shift(self.time5) == "-10")
#(qtpylib.crossed_above(dataframe['macd'], dataframe['macdsignal']))
),
['enter_long', 'enter_tag']] = (1, 'long_signal_chan')
dataframe.loc[
(
(dataframe[state_str].shift(shift_time) == "10")
#(dataframe[fx_str].shift(shift_time) == 1)
#(dataframe[chanpy_state_str].shift(shift_time+30) == -1)
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time5)].shift(self.time5) == "-10") &
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time30)] == "-10") &
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time5)].shift(self.time5) == "-10")
#(qtpylib.crossed_above(dataframe['macd'], dataframe['macdsignal']))
),
['enter_short', 'enter_tag']] = (1, 'short_signal_chan')
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
state_str = 'resample_{}_state'.format(self.get_ticker_indicator()*self.time30)
fx_str = 'resample_{}_fx'.format(self.get_ticker_indicator()*self.time30)
#chanpy_state_str = 'resample_{}_chanpy_state'.format(self.get_ticker_indicator()*self.time5)
shift_time = self.time30
dataframe.loc[
(
#(dataframe['state']== "30")
(dataframe[state_str].shift(shift_time) == "10")
#(dataframe[fx_str].shift(shift_time) == 1)
#(dataframe[chanpy_state_str].shift(shift_time+30) == -1)
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time30)] == "10") &
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time60)] == "10")
),
['exit_long', 'exit_tag']] = (1, 'long_close_signal_chan')
dataframe.loc[
(
#(dataframe['state']== "30")
(dataframe[state_str].shift(shift_time) == "-10")
#(dataframe[fx_str].shift(shift_time) == -1)
#(dataframe[chanpy_state_str].shift(shift_time+30) == 1)
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time30)] == "10") &
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time60)] == "10")
),
['exit_short', 'exit_tag']] = (1, 'short_close_signal_chan')
return dataframe
def leverage(self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str,
**kwargs) -> float:
return self.lev
def get_ticker_indicator(self):
return int(self.timeframe[:-1])