560 lines
28 KiB
Python
560 lines
28 KiB
Python
import logging
|
|
from functools import reduce
|
|
from typing import Dict
|
|
import numpy as np
|
|
import talib.abstract as ta
|
|
from pandas import DataFrame
|
|
from technical import qtpylib
|
|
from talib import MACD, RSI
|
|
from datetime import datetime
|
|
import pandas as pd
|
|
import uuid
|
|
|
|
from freqtrade.strategy import IStrategy
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# freqtrade backtesting --config user_data/ChanLun_XGB.json --strategy ChanLun_XGB --freqaimodel XGBoostClassifier --timerange=20250401-20250421
|
|
|
|
class ChanLun_XGB1(IStrategy):
|
|
minimal_roi = {"0": 0.1, "240": -1}
|
|
plot_config = {
|
|
"main_plot": {},
|
|
"subplots": {
|
|
"&-s_close": {"&-s_close": {"color": "blue"}},
|
|
"do_predict": {"do_predict": {"color": "brown"}},
|
|
},
|
|
}
|
|
process_only_new_candles = True
|
|
stoploss = -0.05
|
|
use_exit_signal = True
|
|
startup_candle_count: int = 40
|
|
can_short = True
|
|
|
|
freqai_info = {
|
|
"feature_parameters": {
|
|
"label_period_candles": 24
|
|
}
|
|
}
|
|
|
|
def feature_engineering_expand_all(self, dataframe: DataFrame, period: int, metadata: Dict, **kwargs) -> DataFrame:
|
|
"""Basic technical indicators for various periods."""
|
|
logger.info("Starting feature_engineering_expand_all")
|
|
dataframe["%-rsi-period"] = ta.RSI(dataframe, timeperiod=period)
|
|
dataframe["%-mfi-period"] = ta.MFI(dataframe, timeperiod=period)
|
|
dataframe["%-adx-period"] = ta.ADX(dataframe, timeperiod=period)
|
|
dataframe["%-sma-period"] = ta.SMA(dataframe, timeperiod=period)
|
|
dataframe["%-ema-period"] = ta.EMA(dataframe, timeperiod=period)
|
|
|
|
bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=period, stds=2.2)
|
|
dataframe["bb_lowerband-period"] = bollinger["lower"]
|
|
dataframe["bb_middleband-period"] = bollinger["mid"]
|
|
dataframe["bb_upperband-period"] = bollinger["upper"]
|
|
|
|
dataframe["%-bb_width-period"] = (
|
|
(dataframe["bb_upperband-period"] - dataframe["bb_lowerband-period"]) / dataframe["bb_middleband-period"]
|
|
)
|
|
dataframe["%-close-bb_lower-period"] = dataframe["close"] / dataframe["bb_lowerband-period"]
|
|
dataframe["%-roc-period"] = ta.ROC(dataframe, timeperiod=period)
|
|
dataframe["%-relative_volume-period"] = dataframe["volume"] / dataframe["volume"].rolling(period).mean()
|
|
|
|
logger.info("Completed feature_engineering_expand_all")
|
|
return dataframe.fillna(0)
|
|
|
|
def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame:
|
|
"""Basic price and volume features."""
|
|
logger.info("Starting feature_engineering_expand_basic")
|
|
dataframe["%-pct-change"] = dataframe["close"].pct_change()
|
|
dataframe["%-raw_volume"] = dataframe["volume"]
|
|
dataframe["%-raw_price"] = dataframe["close"]
|
|
logger.info("Completed feature_engineering_expand_basic")
|
|
return dataframe.fillna(0)
|
|
|
|
def feature_engineering_standard(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame:
|
|
"""Advanced feature engineering with Chan Lun and technical indicators."""
|
|
logger.info(f"Starting feature_engineering_standard for pair {metadata.get('pair', 'unknown')}")
|
|
if dataframe.empty:
|
|
return dataframe
|
|
|
|
try:
|
|
df = dataframe.copy()
|
|
|
|
# Time-based features
|
|
df["%-day_of_week"] = df["date"].dt.dayofweek
|
|
df["%-hour_of_day"] = df["date"].dt.hour
|
|
|
|
# Fractal detection
|
|
df = self.detect_fractals(df)
|
|
logger.info("Fractal detection completed")
|
|
|
|
# MACD and RSI
|
|
macd, signal, hist = MACD(df['close'], fastperiod=12, slowperiod=26, signalperiod=9)
|
|
df['macd'] = macd
|
|
df['macd_signal'] = signal
|
|
df['macd_hist'] = hist
|
|
df['macd_hist_sum'] = df['macd_hist'].rolling(5).sum()
|
|
df["%-rsi-14"] = RSI(df['close'], timeperiod=14)
|
|
logger.info("MACD and RSI calculated")
|
|
|
|
# Candlestick features
|
|
df["%-close_open_diff"] = (df["close"] - df["open"]) / df["open"].replace(0, np.nan)
|
|
df["%-body_length"] = abs(df["close"] - df["open"]) / df["close"].replace(0, np.nan)
|
|
df["%-upper_shadow"] = (df["high"] - df[["open", "close"]].max(axis=1)) / df["close"].replace(0, np.nan)
|
|
df["%-lower_shadow"] = (df[["open", "close"]].min(axis=1) - df["low"]) / df["close"].replace(0, np.nan)
|
|
|
|
# Candle color and trend
|
|
df["%-candle_color"] = (df["close"] > df["open"]).astype(int) * 2 - 1
|
|
df["%-consec_same_color"] = df["%-candle_color"].groupby((df["%-candle_color"] != df["%-candle_color"].shift()).cumsum()).cumcount() + 1
|
|
|
|
# Fractal strength
|
|
df["%-bottom_strength"], df["%-top_strength"] = self.calculate_fractal_strength(df)
|
|
logger.info("Fractal strength calculated")
|
|
|
|
# Price relationships
|
|
for i in [1, 2, 3]:
|
|
high_shift = df['high'].shift(i).replace(0, df['high'].mean())
|
|
low_shift = df['low'].shift(i).replace(0, df['low'].mean())
|
|
df[f"%-high_ratio_{i}"] = df['high'] / high_shift
|
|
df[f"%-low_ratio_{i}"] = df['low'] / low_shift
|
|
|
|
# MACD divergence
|
|
df["%-macd_bottom_div"] = ((df['low'] < df['low'].rolling(5).min().shift(1)) & (df['macd'] > df['macd'].rolling(5).min().shift(1))).astype(int)
|
|
df["%-macd_top_div"] = ((df['high'] > df['high'].rolling(5).max().shift(1)) & (df['macd'] < df['macd'].rolling(5).max().shift(1))).astype(int)
|
|
|
|
# Additional features
|
|
df["%-volume_change"] = df['volume'].pct_change()
|
|
df["%-volatility"] = df['close'].rolling(5).std()
|
|
df["%-price_range_20"] = (df['high'].rolling(5).max() - df['low'].rolling(5).min()) / df['close'].replace(0, np.nan)
|
|
df["%-potential_top"] = (df['is_top'] & (df["%-rsi-14"] > 70) & (df["%-macd_top_div"] == 1) &
|
|
(df['volume'] > df['volume'].rolling(20).mean()) & (df['close'] < df['open'])).astype(int)
|
|
df["%-potential_bottom"] = (df['is_bottom'] & (df["%-rsi-14"] < 30) & (df["%-macd_bottom_div"] == 1)).astype(int)
|
|
|
|
# Fractal distance
|
|
df["%-last_fractal_distance"] = self.calculate_fractal_distance(df)
|
|
|
|
# Advanced features
|
|
df["%-macd_hist_change"] = df['macd_hist_sum'].pct_change().replace([np.inf, -np.inf], 0)
|
|
df["%-volume_divergence"] = (df['close'].pct_change() - df['volume'].pct_change()).abs()
|
|
df["%-breakout_high"] = (df['high'] > df['high'].shift(1).rolling(20).max()).astype(int)
|
|
df["%-breakout_low"] = (df['low'] < df['low'].shift(1).rolling(20).min()).astype(int)
|
|
df["%-top_prominence"] = (df['high'] - df['high'].shift(1).rolling(5).mean()) / (df['high'].shift(1).rolling(5).std() + 1e-6)
|
|
df["%-top_prominence"] = df["%-top_prominence"].clip(-100, 100)
|
|
df["%-macd_hist_decline"] = df['macd_hist'].rolling(3).apply(
|
|
lambda x: 1 if all(x[i] > x[i+1] for i in range(len(x)-1)) else 0, raw=True)
|
|
df["%-top_candle_pattern"] = ((df['close'].shift(1) > df['open'].shift(1)) &
|
|
(df['close'] < df['open']) &
|
|
(df['close'] < df['open'].shift(1))).astype(int)
|
|
df["%-bottom_combo"] = (df['is_bottom'] & (df["%-rsi-14"] < 40) & (df['macd_hist'] > 0) &
|
|
(df['volume'] > df['volume'].rolling(20).mean())).astype(int)
|
|
df["%-top_combo"] = (df['is_top'] & (df["%-rsi-14"] > 60) & (df['macd_hist'] < 0) &
|
|
(df['volume'] > df['volume'].rolling(20).mean())).astype(int)
|
|
df["%-post_top_decline"] = self.calculate_post_top_decline(df)
|
|
df["%-resistance_distance"] = self.calculate_resistance_distance(df)
|
|
|
|
# Stroke and pivot features
|
|
strokes = self.detect_strokes(df)
|
|
df["%-macd_hist_dynamic"], df["%-pivot_distance"], df["%-buy_signal"], df["%-sell_signal"] = self.process_strokes_and_pivots(df, strokes)
|
|
logger.info("Stroke and pivot features completed")
|
|
|
|
# Clean up
|
|
num_columns = df.select_dtypes(include=[np.number]).columns
|
|
df[num_columns] = df[num_columns].replace([np.inf, -np.inf], 0).fillna(0)
|
|
logger.info(f"Completed feature_engineering_standard. Columns: {list(df.columns)}")
|
|
return df
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in feature_engineering_standard: {e}")
|
|
# 发生错误时返回原始数据框
|
|
return dataframe
|
|
|
|
def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame:
|
|
"""Set prediction targets for FreqAI."""
|
|
logger.info("Setting FreqAI targets")
|
|
label_period = self.freqai_info["feature_parameters"]["label_period_candles"]
|
|
|
|
# Calculate future return
|
|
future_return = (
|
|
dataframe["close"].shift(-label_period).rolling(label_period).mean() / dataframe["close"] - 1
|
|
)
|
|
|
|
# Discretize into categorical labels: 1 (buy), 2 (sell)
|
|
dataframe["&-s_close"] = pd.Series(0, index=dataframe.index) # Default: no trade
|
|
dataframe.loc[future_return > 0.01, "&-s_close"] = 1 # Buy if return > 1%
|
|
dataframe.loc[future_return < -0.01, "&-s_close"] = 0 # Sell if return < -1%
|
|
|
|
return dataframe.fillna(0)
|
|
|
|
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""Populate indicators, leveraging FreqAI if available."""
|
|
logger.info(f"Starting populate_indicators for pair {metadata.get('pair', 'unknown')}")
|
|
# Always run feature_engineering_standard to ensure custom features
|
|
dataframe = self.feature_engineering_standard(dataframe, metadata)
|
|
|
|
if hasattr(self, "freqai"):
|
|
logger.info("Running FreqAI pipeline")
|
|
# Preserve custom features
|
|
custom_features = [col for col in dataframe.columns if col.startswith('%-')]
|
|
temp_df = dataframe[custom_features + ['date', 'close', 'open', 'high', 'low', 'volume']]
|
|
# Run FreqAI
|
|
freqai_df = self.freqai.start(dataframe, metadata, self)
|
|
# Merge back custom features
|
|
freqai_df = freqai_df.combine_first(temp_df)
|
|
dataframe = freqai_df
|
|
logger.info(f"Completed populate_indicators. Columns: {list(dataframe.columns)}")
|
|
return dataframe
|
|
|
|
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""Define entry signals."""
|
|
logger.info(f"Starting populate_entry_trend for pair {metadata.get('pair', 'unknown')}")
|
|
df = self.ensure_columns(dataframe, ['%-buy_signal', '%-bottom_strength', '%-sell_signal', '%-top_strength', '%-macd_top_div', '%-rsi-14'])
|
|
|
|
enter_long_conditions = [
|
|
df["do_predict"] == 1,
|
|
df["&-s_close"] == 1, # 上涨预测
|
|
df["%-buy_signal"] > 0,
|
|
df["%-bottom_strength"] > 2.0,
|
|
df["%-rsi-14"] < 40,
|
|
]
|
|
if enter_long_conditions:
|
|
df.loc[reduce(lambda x, y: x & y, enter_long_conditions), ["enter_long", "enter_tag"]] = (1, "long")
|
|
|
|
enter_short_conditions = [
|
|
df["do_predict"] == 1,
|
|
df["&-s_close"] == 0, # 下跌预测
|
|
df["%-sell_signal"] > 0,
|
|
df["%-top_strength"] > 2.5,
|
|
df["%-rsi-14"] > 60,
|
|
df["%-macd_top_div"] > 0,
|
|
]
|
|
if enter_short_conditions:
|
|
df.loc[reduce(lambda x, y: x & y, enter_short_conditions), ["enter_short", "enter_tag"]] = (1, "short")
|
|
|
|
logger.info("Completed populate_entry_trend")
|
|
return df
|
|
|
|
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""Define exit signals."""
|
|
logger.info(f"Starting populate_exit_trend for pair {metadata.get('pair', 'unknown')}")
|
|
df = self.ensure_columns(dataframe, ['%-top_combo', '%-top_candle_pattern', '%-bottom_combo', '%-bottom_strength', '%-rsi-14'])
|
|
|
|
exit_long_conditions = [
|
|
df["do_predict"] == 1,
|
|
df["&-s_close"] == 0, # 下跌预测时退出多头
|
|
(df["%-top_combo"] > 0) | (df["%-top_candle_pattern"] > 0)
|
|
]
|
|
if exit_long_conditions:
|
|
df.loc[reduce(lambda x, y: x & y, exit_long_conditions), "exit_long"] = 1
|
|
|
|
exit_short_conditions = [
|
|
df["do_predict"] == 1,
|
|
df["&-s_close"] == 1, # 上涨预测时退出空头
|
|
(df["%-bottom_combo"] > 0) | (df["%-bottom_strength"] > 3.0)
|
|
]
|
|
if exit_short_conditions:
|
|
df.loc[reduce(lambda x, y: x & y, exit_short_conditions), "exit_short"] = 1
|
|
|
|
logger.info("Completed populate_exit_trend")
|
|
return df
|
|
|
|
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str,
|
|
current_time, entry_tag, side: str, **kwargs) -> bool:
|
|
"""Confirm trade entry with additional checks."""
|
|
logger.info(f"Confirming trade entry for {pair}, side: {side}")
|
|
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
|
last_candle = df.iloc[-1].squeeze()
|
|
df = self.ensure_columns(df, ['%-bottom_strength', '%-buy_signal', '%-top_strength', '%-sell_signal', '%-rsi-14'])
|
|
|
|
if side == "long":
|
|
if rate > (last_candle["close"] * 1.0025):
|
|
logger.debug(f"Long entry rejected: rate {rate} exceeds threshold")
|
|
return False
|
|
return last_candle["%-bottom_strength"] > 1.5 or last_candle["%-buy_signal"] > 0
|
|
else:
|
|
if rate < (last_candle["close"] * 0.9975):
|
|
logger.debug(f"Short entry rejected: rate {rate} below threshold")
|
|
return False
|
|
return last_candle["%-top_strength"] > 2.0 or last_candle["%-sell_signal"] > 0
|
|
|
|
def detect_fractals(self, df: DataFrame) -> DataFrame:
|
|
"""Detect top and bottom fractals."""
|
|
logger.debug("Detecting fractals")
|
|
df['is_top'] = (
|
|
(df['high'] > df['high'].shift(1)) & (df['high'] > df['high'].shift(2)) &
|
|
(df['high'] > df['high'].shift(-1)) & (df['high'] > df['high'].shift(-2))
|
|
)
|
|
df['is_bottom'] = (
|
|
(df['low'] < df['low'].shift(1)) & (df['low'] < df['low'].shift(2)) &
|
|
(df['low'] < df['low'].shift(-1)) & (df['low'] < df['low'].shift(-2))
|
|
)
|
|
return df.fillna({'is_top': False, 'is_bottom': False})
|
|
|
|
def calculate_fractal_strength(self, df: DataFrame) -> tuple:
|
|
"""Calculate strength of fractals."""
|
|
logger.debug("Calculating fractal strength")
|
|
bottom_strength = np.zeros(len(df))
|
|
top_strength = np.zeros(len(df))
|
|
|
|
for i in range(2, len(df) - 2):
|
|
if df['is_bottom'].iloc[i]:
|
|
strength = 0.0
|
|
pre_decline = (df['low'].iloc[i-2:i].min() - df['low'].iloc[i]) / df['low'].iloc[i]
|
|
post_rise = (df['high'].iloc[i+1:i+3].max() - df['high'].iloc[i]) / df['high'].iloc[i]
|
|
strength += min(pre_decline * 10, 1.0) + min(post_rise * 10, 1.0)
|
|
vol_surge = df['volume'].iloc[i] / df['volume'].iloc[i-3:i].mean() if df['volume'].iloc[i-3:i].mean() > 0 else 1
|
|
strength += min(vol_surge / 3, 1.0)
|
|
if any(abs(df['low'].iloc[j] - df['low'].iloc[i]) / df['low'].iloc[i] < 0.01 for j in range(max(0, i-20), i)):
|
|
strength += 1.0
|
|
bottom_strength[i] = min(strength, 5.0)
|
|
|
|
if df['is_top'].iloc[i]:
|
|
strength = 0.0
|
|
pre_rise = (df['high'].iloc[i] - df['high'].iloc[i-2:i].max()) / df['high'].iloc[i]
|
|
post_decline = (df['low'].iloc[i] - df['low'].iloc[i+1:i+3].min()) / df['low'].iloc[i]
|
|
strength += min(pre_rise * 10, 1.0) + min(post_decline * 10, 1.0)
|
|
vol_surge = df['volume'].iloc[i] / df['volume'].iloc[i-3:i].mean() if df['volume'].iloc[i-3:i].mean() > 0 else 1
|
|
strength += min(vol_surge / 3, 1.0)
|
|
if any(abs(df['high'].iloc[j] - df['high'].iloc[i]) / df['high'].iloc[i] < 0.01 for j in range(max(0, i-20), i)):
|
|
strength += 1.0
|
|
bearish_count = sum(df['close'].iloc[i:i+3] < df['open'].iloc[i:i+3])
|
|
strength += min(bearish_count * 0.5, 1.5)
|
|
top_strength[i] = min(strength, 6.0)
|
|
|
|
return bottom_strength, top_strength
|
|
|
|
def calculate_fractal_distance(self, df: DataFrame) -> pd.Series:
|
|
"""Calculate distance to last fractal."""
|
|
logger.debug("Calculating fractal distance")
|
|
fractal_indices = df[df['is_top'] | df['is_bottom']].index
|
|
distances = pd.Series(0, index=df.index)
|
|
|
|
for i in range(1, len(df)):
|
|
if fractal_indices[fractal_indices < df.index[i]].size > 0:
|
|
last_fractal_idx = fractal_indices[fractal_indices < df.index[i]][-1]
|
|
if isinstance(df.index[i], pd.Timestamp) and isinstance(last_fractal_idx, pd.Timestamp):
|
|
time_diff = (df.index[i] - last_fractal_idx).total_seconds() / 60
|
|
else:
|
|
time_diff = i - df.index.get_loc(last_fractal_idx)
|
|
distances.iloc[i] = time_diff
|
|
return distances
|
|
|
|
def calculate_post_top_decline(self, df: DataFrame) -> pd.Series:
|
|
"""Calculate post-top decline."""
|
|
logger.debug("Calculating post-top decline")
|
|
post_top_decline = pd.Series(0.0, index=df.index)
|
|
for i in range(2, len(df)-3):
|
|
if df['is_top'].iloc[i]:
|
|
decline = (df['high'].iloc[i] - df['low'].iloc[i+1:i+4].min()) / df['high'].iloc[i]
|
|
post_top_decline.iloc[i] = min(decline * 10, 5.0)
|
|
return post_top_decline
|
|
|
|
def calculate_resistance_distance(self, df: DataFrame) -> pd.Series:
|
|
"""Calculate resistance distance."""
|
|
logger.debug("Calculating resistance distance")
|
|
resistance_distance = pd.Series(0.0, index=df.index)
|
|
for i in range(20, len(df)):
|
|
if df['is_top'].iloc[i]:
|
|
price_high = df['high'].iloc[i]
|
|
resistance_levels = df['high'].iloc[i-20:i].rolling(5).max()
|
|
distance = (price_high - resistance_levels.min()) / price_high if resistance_levels.min() > 0 else 0
|
|
resistance_distance.iloc[i] = min(distance * 10, 5.0)
|
|
return resistance_distance
|
|
|
|
def detect_strokes(self, df: DataFrame) -> list:
|
|
"""Detect strokes based on fractals."""
|
|
logger.debug("Detecting strokes")
|
|
strokes = []
|
|
last_fractal, last_price, last_index = None, None, None
|
|
|
|
for i in range(len(df)):
|
|
if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]:
|
|
current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom'
|
|
current_price = df['high'].iloc[i] if current_fractal == 'top' else df['low'].iloc[i]
|
|
|
|
if last_fractal is None:
|
|
last_fractal, last_price, last_index = current_fractal, current_price, df.index[i]
|
|
continue
|
|
|
|
if not isinstance(current_price, (int, float)) or not isinstance(last_price, (int, float)):
|
|
continue
|
|
|
|
price_change = abs(current_price - last_price) / last_price
|
|
if price_change < 0.005:
|
|
continue
|
|
|
|
if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \
|
|
(last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price):
|
|
strokes.append({
|
|
'start_time': last_index,
|
|
'end_time': df.index[i],
|
|
'start_price': last_price,
|
|
'end_price': current_price,
|
|
'type': 'down' if current_fractal == 'bottom' else 'up'
|
|
})
|
|
|
|
last_fractal, last_price, last_index = current_fractal, current_price, df.index[i]
|
|
|
|
logger.debug(f"Detected {len(strokes)} strokes")
|
|
return strokes
|
|
|
|
def detect_pivots(self, strokes: list) -> list:
|
|
"""Detect pivots based on strokes."""
|
|
logger.debug("Detecting pivots")
|
|
pivots = []
|
|
if len(strokes) < 3:
|
|
logger.debug("Insufficient strokes for pivot detection")
|
|
return pivots
|
|
|
|
for i in range(2, len(strokes)):
|
|
high1, low1 = max(strokes[i-2]['start_price'], strokes[i-2]['end_price']), min(strokes[i-2]['start_price'], strokes[i-2]['end_price'])
|
|
high2, low2 = max(strokes[i-1]['start_price'], strokes[i-1]['end_price']), min(strokes[i-1]['start_price'], strokes[i-1]['end_price'])
|
|
high3, low3 = max(strokes[i]['start_price'], strokes[i]['end_price']), min(strokes[i]['start_price'], strokes[i]['end_price'])
|
|
if max(low1, low2, low3) < min(high1, high2, high3):
|
|
pivots.append({
|
|
'start_time': strokes[i-2]['start_time'],
|
|
'end_time': strokes[i]['end_time'],
|
|
'high': min(high1, high2, high3),
|
|
'low': max(low1, low2, low3)
|
|
})
|
|
logger.debug(f"Detected {len(pivots)} pivots")
|
|
return pivots
|
|
|
|
def add_pivot_distance(self, df: DataFrame, pivots: list) -> DataFrame:
|
|
"""Add pivot distance feature."""
|
|
logger.debug("Adding pivot distance")
|
|
df = df.copy()
|
|
df['pivot_distance'] = 0.0
|
|
|
|
if not pivots:
|
|
logger.debug("No pivots detected, returning default pivot_distance")
|
|
return df
|
|
|
|
for pivot in pivots:
|
|
try:
|
|
start_time = pivot['start_time']
|
|
end_time = pivot['end_time']
|
|
if start_time not in df.index or end_time not in df.index:
|
|
logger.debug(f"Invalid pivot times: {start_time} to {end_time}")
|
|
continue
|
|
mask = (df.index >= start_time) & (df.index <= end_time)
|
|
denominator = pivot['high'] - pivot['low']
|
|
if denominator > 0:
|
|
df.loc[mask, 'pivot_distance'] = (df['close'] - pivot['low']) / denominator
|
|
else:
|
|
logger.debug(f"Zero denominator for pivot {pivot}")
|
|
except Exception as e:
|
|
logger.error(f"Error in pivot distance calculation: {e}")
|
|
continue
|
|
|
|
df['pivot_distance'] = df['pivot_distance'].clip(-10, 10).fillna(0.0)
|
|
logger.debug("Completed pivot distance calculation")
|
|
return df
|
|
|
|
def detect_back_divergence(self, df: DataFrame, strokes: list) -> DataFrame:
|
|
"""Detect back divergence for buy/sell signals."""
|
|
logger.debug("Detecting back divergence")
|
|
df = df.copy()
|
|
df['buy_signal'] = False
|
|
df['sell_signal'] = False
|
|
|
|
if len(strokes) < 2:
|
|
logger.debug("Insufficient strokes for divergence detection")
|
|
return df
|
|
|
|
for i in range(1, len(strokes)):
|
|
current_hist = self.safe_get_value(df, strokes[i]['end_time'], 'macd_hist_sum')
|
|
previous_hist = self.safe_get_value(df, strokes[i-1]['end_time'], 'macd_hist_sum')
|
|
|
|
if current_hist is None or previous_hist is None:
|
|
continue
|
|
|
|
if strokes[i]['type'] == strokes[i-1]['type'] == 'up':
|
|
if strokes[i]['end_price'] > strokes[i-1]['end_price'] and current_hist < previous_hist:
|
|
closest_time = df.index[df.index <= strokes[i]['end_time']]
|
|
if len(closest_time) > 0:
|
|
df.loc[closest_time[-1], 'sell_signal'] = True
|
|
|
|
elif strokes[i]['type'] == strokes[i-1]['type'] == 'down':
|
|
if strokes[i]['end_price'] < strokes[i-1]['end_price'] and current_hist < previous_hist:
|
|
closest_time = df.index[df.index <= strokes[i]['end_time']]
|
|
if len(closest_time) > 0:
|
|
df.loc[closest_time[-1], 'buy_signal'] = True
|
|
|
|
logger.debug("Completed back divergence detection")
|
|
return df
|
|
|
|
def process_strokes_and_pivots(self, df: DataFrame, strokes: list) -> tuple:
|
|
"""Process strokes and pivots for advanced features."""
|
|
logger.debug("Processing strokes and pivots")
|
|
macd_hist_dynamic = pd.Series(0.0, index=df.index)
|
|
pivot_distance = pd.Series(0.0, index=df.index)
|
|
buy_signal = pd.Series(0, index=df.index)
|
|
sell_signal = pd.Series(0, index=df.index)
|
|
|
|
if strokes:
|
|
try:
|
|
for stroke in strokes[1:]:
|
|
start_time, end_time = stroke['start_time'], stroke['end_time']
|
|
if start_time not in df.index or end_time not in df.index:
|
|
logger.debug(f"Invalid stroke times: {start_time} to {end_time}")
|
|
continue
|
|
|
|
window = self.calculate_window(df, start_time, end_time)
|
|
start_idx, end_idx = df.index.get_loc(start_time), df.index.get_loc(end_time) + 1
|
|
hist_values = df['macd_hist'].iloc[start_idx:end_idx].rolling(window, min_periods=1).sum().fillna(0)
|
|
macd_hist_dynamic.iloc[start_idx:end_idx] = hist_values
|
|
except Exception as e:
|
|
logger.warning(f"Dynamic MACD calculation error: {e}")
|
|
|
|
try:
|
|
pivots = self.detect_pivots(strokes)
|
|
if pivots:
|
|
df_with_pivot = self.add_pivot_distance(df, pivots)
|
|
pivot_distance = df_with_pivot['pivot_distance']
|
|
else:
|
|
logger.debug("No pivots detected")
|
|
except Exception as e:
|
|
logger.warning(f"Pivot detection error: {e}")
|
|
|
|
try:
|
|
df_with_divergence = self.detect_back_divergence(df, strokes)
|
|
buy_signal = df_with_divergence['buy_signal'].astype(int)
|
|
sell_signal = df_with_divergence['sell_signal'].astype(int)
|
|
except Exception as e:
|
|
logger.warning(f"Back divergence detection error: {e}")
|
|
|
|
logger.debug("Completed stroke and pivot processing")
|
|
return macd_hist_dynamic, pivot_distance, buy_signal, sell_signal
|
|
|
|
def ensure_columns(self, df: DataFrame, columns: list) -> DataFrame:
|
|
"""Ensure required columns exist with default values."""
|
|
logger.debug(f"Ensuring columns: {columns}")
|
|
for col in columns:
|
|
if col not in df.columns:
|
|
logger.warning(f"Column {col} missing, using default value")
|
|
df[col] = 50 if col == '%-rsi-14' else 0
|
|
return df
|
|
|
|
def safe_get_value(self, df: DataFrame, timestamp, column: str):
|
|
"""Safely get value from DataFrame."""
|
|
try:
|
|
if timestamp in df.index:
|
|
return df.loc[timestamp, column]
|
|
closest_idx = df.index[df.index <= timestamp]
|
|
return df.loc[closest_idx[-1], column] if len(closest_idx) > 0 else None
|
|
except Exception as e:
|
|
logger.debug(f"Error getting value for {column} at {timestamp}: {e}")
|
|
return None
|
|
|
|
def calculate_window(self, df: DataFrame, start_time, end_time) -> int:
|
|
"""Calculate window size for dynamic features."""
|
|
try:
|
|
if isinstance(start_time, pd.Timestamp) and isinstance(end_time, pd.Timestamp):
|
|
window = int((end_time - start_time).total_seconds() / 60)
|
|
else:
|
|
start_idx = df.index.get_loc(start_time)
|
|
end_idx = df.index.get_loc(end_time)
|
|
window = end_idx - start_idx
|
|
return max(window, 1)
|
|
except (TypeError, AttributeError, KeyError) as e:
|
|
logger.debug(f"Window calculation error: {e}")
|
|
return 1 |