Timeframe
5m
Direction
Long Only
Stoploss
-99.0%
Trailing Stop
No
ROI
N/A
Interface Version
3
Startup Candles
N/A
Indicators
3
i1ya/freqtrade-strategies
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
# flake8: noqa: F401
"""
FFT_AdaptiveCycle_v1
====================
ARCHITECTURE
------------
1. Causal FFT cycle detector → dominant period P + quality metrics
(log-return window, Hann taper, parabolic peak interpolation)
2. Kalman filter → K_period: stable period with no drift/float
When FFT detects valid cycle: update with low noise R.
When FFT sees noise: prediction-only (period drifts at rate Q per bar).
3. Adaptive biquad bandpass → BP: pure cycle component of price
Center = 1/K_period, Q = 1/bw_ratio (default Q≈3.3 for 30% bandwidth).
Coefficients updated per bar → filter adapts as cycle period shifts.
4. Cycle oscillator → bp_z = BP / rolling_RMS(BP, 50 bars)
Values near ±1 = typical extremes. ±2 = strong extremes.
5. EMA-200 slope → macro trend direction (up/down/flat)
6. Entry: cycle crossing (trough→up or peak→down) + trend + quality gate
7. Exit: populate_exit_trend (cycle completion) + custom_exit (trailing TP)
8. DCA: linear price steps from open_rate, exponential volume scale
9. Leverage: 2×
KEY DESIGN CHOICES (based on community feedback)
-------------------------------------------------
- Kalman filter solves the "float" problem: raw FFT period jumps bar-to-bar;
Kalman treats valid detections as noisy measurements and carries forward
a stable estimate between detections.
- Bandpass center tracks K_period not the raw FFT → no coefficient discontinuities.
- Entry on cycle CROSSING (not level) fires once per trough/peak, not every bar.
- Trend filter (EMA-200 slope) avoids counter-trend mean-reversion in strong moves.
- Trailing retrace = 0.5% tuned for 2× leverage noise floor (~0.25% price tick).
PARAMETERS (config.json overrides)
-----------------------------------
fft_window int = 128 bars for FFT window
fft_min_period int = 8 min detectable cycle
fft_max_period int = 100 max detectable cycle
fft_default_period int = 40 fallback period
kalman_q float = 2.0 process noise (period drift rate)
kalman_r float = 9.0 measurement noise (valid detections)
bp_bandwidth float = 0.3 bandpass bandwidth ratio (Q = 1/0.3 ≈ 3.3)
bp_z_window int = 50 rolling RMS window for oscillator
bp_z_entry float = 0.8 |oscillator| crossing threshold for entry
bp_z_exit float = 0.7 oscillator threshold for cycle-completion exit
use_trend_filter bool = True require EMA-200 slope alignment
trend_ema_period int = 200 EMA period for macro trend
trend_slope_bars int = 10 bars over which EMA slope is measured
min_cycle_dominance float = 0.12 minimum FFT dominance to allow entries
use_cycle_exit bool = True exit via cycle-completion signal
take_profit float = 0.010 1.0% profit target (leveraged)
trailing_after_tp bool = True trailing mode after TP hit
trailing_min_activation float = 0.005 trailing arms at TP + 0.5%
trailing_retrace float = 0.005 exit on 0.5% pullback from peak
force_exit_by_days_en bool = True
force_exit_after_days float = 2.0
safety_order_ratio float = 1.2
safety_order_max_count int = 5
safety_order_vol_scale float = 1.2
price_deviation_initial float = 0.02
DCA COVERAGE (stake=50, ratio=1.2, scale=1.2, dev=2%, leverage=2×)
--------------------------------------------------------------------
Level Price drop P&L drop Order(USDT) Cumul(USDT)
Entry 0% 0% 50 50
DCA 1 -2% -4% 60 110
DCA 2 -4% -8% 72 182
DCA 3 -6% -12% 86 268
DCA 4 -8% -16% 104 372
DCA 5 -10% -20% 124 496
Total worst-case: 496 USDT / trade
RESTART SAFETY
--------------
Trailing TP state (tp_activated, max_profit) stored via trade.set_custom_data()
— survives bot restarts, crashes, and /reload_config.
CHANGELOG
---------
v1.0: Initial release. Kalman + biquad bandpass + crossing entry + trailing TP.
"""
import sys
import math
import logging
import numpy as np
import pandas as pd
from datetime import datetime
from pandas import DataFrame
from typing import Optional, Tuple, Any
# Windows UTF-8 fix
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from freqtrade.strategy import IStrategy, Trade
import talib.abstract as ta
logger = logging.getLogger(__name__)
class FFTAdaptiveCycle(IStrategy):
INTERFACE_VERSION = 3
timeframe = "5m"
can_short: bool = True
minimal_roi = {}
stoploss = -0.99
trailing_stop = False
process_only_new_candles = True
use_exit_signal = True
startup_candle_count: int = 300
position_adjustment_enable = True
max_entry_position_adjustment = 7
# ── FFT ──────────────────────────────────────────────────────────────────
fft_window: int = 128
fft_min_period: int = 8
fft_max_period: int = 100
fft_default_period: int = 40
fft_smooth_span: int = 5
fft_peak_ratio_min: float = 4.0
fft_entropy_max: float = 0.85
# ── Kalman ───────────────────────────────────────────────────────────────
kalman_q: float = 2.0
kalman_r: float = 9.0
# ── Bandpass + oscillator ─────────────────────────────────────────────────
bp_bandwidth: float = 0.3
bp_z_window: int = 50
bp_z_entry: float = 0.8
# ── Trend ─────────────────────────────────────────────────────────────────
use_trend_filter: bool = True
trend_ema_period: int = 200
trend_slope_bars: int = 10
min_cycle_dominance: float = 0.12
# ── Take Profit / Trailing ────────────────────────────────────────────────
take_profit: float = 0.010
trailing_after_tp: bool = True
trailing_min_activation: float = 0.005
trailing_retrace: float = 0.003
force_exit_by_days_enabled: bool = True
force_exit_after_days: float = 2.0
# ── DCA ───────────────────────────────────────────────────────────────────
safety_order_ratio: float = 1.2
safety_order_max_count: int = 7
safety_order_volume_scale: float = 1.2
price_deviation_initial: float = 0.02
_EXIT_STATE_KEY = "exit_state"
_exit_states: dict = {}
# =========================================================================
# INIT
# =========================================================================
def __init__(self, config: dict) -> None:
super().__init__(config)
if sys.platform == "win32":
for _h in logging.root.handlers:
if isinstance(_h, logging.FileHandler):
if getattr(_h, "encoding", "utf-8") not in ("utf-8", "utf8"):
try:
_h.close()
_h.encoding = "utf-8"
_h.stream = open(
_h.baseFilename, _h.mode,
encoding="utf-8", errors="replace",
)
except Exception:
pass
if "timeframe" in config:
self.timeframe = config["timeframe"]
def _g(key, default):
return config.get(key, default)
self.fft_window = int(_g("fft_window", self.fft_window))
self.fft_min_period = int(_g("fft_min_period", self.fft_min_period))
self.fft_max_period = int(_g("fft_max_period", self.fft_max_period))
self.fft_default_period = int(_g("fft_default_period", self.fft_default_period))
self.fft_smooth_span = int(_g("fft_smooth_span", self.fft_smooth_span))
self.fft_peak_ratio_min = float(_g("fft_peak_ratio_min", self.fft_peak_ratio_min))
self.fft_entropy_max = float(_g("fft_entropy_max", self.fft_entropy_max))
self.kalman_q = float(_g("kalman_q", self.kalman_q))
self.kalman_r = float(_g("kalman_r", self.kalman_r))
self.bp_bandwidth = float(_g("bp_bandwidth", self.bp_bandwidth))
self.bp_z_window = int(_g("bp_z_window", self.bp_z_window))
self.bp_z_entry = float(_g("bp_z_entry", self.bp_z_entry))
self.use_trend_filter = bool(_g("use_trend_filter", self.use_trend_filter))
self.trend_ema_period = int(_g("trend_ema_period", self.trend_ema_period))
self.trend_slope_bars = int(_g("trend_slope_bars", self.trend_slope_bars))
self.min_cycle_dominance = float(_g("min_cycle_dominance", self.min_cycle_dominance))
self.take_profit = float(_g("take_profit", self.take_profit))
self.trailing_after_tp = bool(_g("trailing_after_tp", self.trailing_after_tp))
self.trailing_min_activation = float(_g("trailing_min_activation", self.trailing_min_activation))
self.trailing_retrace = float(_g("trailing_retrace", self.trailing_retrace))
self.force_exit_by_days_enabled = bool(_g("force_exit_by_days_enabled", self.force_exit_by_days_enabled))
self.force_exit_after_days = float(_g("force_exit_after_days", self.force_exit_after_days))
self.safety_order_ratio = float(_g("safety_order_ratio", self.safety_order_ratio))
self.safety_order_max_count = int(_g("safety_order_max_count", self.safety_order_max_count))
self.safety_order_volume_scale = float(_g("safety_order_volume_scale", self.safety_order_volume_scale))
self.price_deviation_initial = float(_g("price_deviation_initial", self.price_deviation_initial))
self.max_entry_position_adjustment = self.safety_order_max_count
logger.info("✅ FFTAdaptiveCycle initialized")
logger.info(f" timeframe = {self.timeframe}")
logger.info(f" fft_window = {self.fft_window}")
logger.info(f" period_range = [{self.fft_min_period}, {self.fft_max_period}]")
logger.info(f" kalman_q/r = {self.kalman_q}/{self.kalman_r}")
logger.info(f" bp_bandwidth = {self.bp_bandwidth:.0%} (Q≈{1/self.bp_bandwidth:.1f})")
logger.info(f" bp_z_entry = ±{self.bp_z_entry}")
logger.info(f" trend_filter = {self.use_trend_filter}")
logger.info(f" min_dominance = {self.min_cycle_dominance}")
logger.info(f" take_profit = {self.take_profit:.1%}")
logger.info(f" trailing_retrace = {self.trailing_retrace:.1%}")
logger.info(f" dca_levels = {self.safety_order_max_count}")
# =========================================================================
# CAUSAL FFT CYCLE DETECTOR
# =========================================================================
@staticmethod
def _next_power_of_two(n: int) -> int:
n = max(1, int(n))
return 1 << (n - 1).bit_length()
@staticmethod
def _linear_detrend_window(x: np.ndarray) -> np.ndarray:
x = np.asarray(x, dtype=float)
L = len(x)
if L < 3:
return x - np.nanmean(x)
j = np.arange(L, dtype=float)
j_mean = (L - 1) / 2.0
x_mean = np.nanmean(x)
jc = j - j_mean
xc = x - x_mean
denom = np.sum(jc * jc)
if denom <= 1e-12:
return x - x_mean
b = np.sum(jc * xc) / denom
a = x_mean - b * j_mean
return x - (a + b * j)
@staticmethod
def _robust_zscore_window(x: np.ndarray, eps: float = 1e-12) -> np.ndarray:
x = np.asarray(x, dtype=float)
med = np.nanmedian(x)
mad = np.nanmedian(np.abs(x - med))
if np.isfinite(mad) and mad > eps:
z = (x - med) / (1.4826 * mad + eps)
else:
mu = np.nanmean(x)
sigma = np.nanstd(x, ddof=1)
if np.isfinite(sigma) and sigma > eps:
z = (x - mu) / (sigma + eps)
else:
z = x - mu
z = z - np.nanmean(z)
return np.nan_to_num(z, nan=0.0, posinf=0.0, neginf=0.0)
def causal_fft_cycle_period(
self,
price: pd.Series,
window_size: int,
min_period: int = 12,
max_period: int = 160,
default_period: int = 80,
smooth_span: int = 10,
peak_ratio_min: float = 4.0,
dominance_min: float = 0.12,
entropy_max: float = 0.85,
) -> DataFrame:
eps = 1e-12
price = (
pd.Series(price, copy=True)
.astype(float)
.replace([np.inf, -np.inf], np.nan)
.ffill()
)
index = price.index
values = price.to_numpy(dtype=float)
n_rows = len(values)
L = int(window_size)
L = max(L, min_period * 2 + 4)
max_period = int(min(max_period, L // 2))
min_period = int(max(3, min_period))
if max_period <= min_period:
max_period = min_period + 1
n_fft = self._next_power_of_two(L)
hann = np.hanning(L)
window_energy = np.mean(hann * hann)
if not np.isfinite(window_energy) or window_energy <= eps:
window_energy = 1.0
freqs = np.fft.rfftfreq(n_fft, d=1.0)
periods = np.full_like(freqs, np.inf, dtype=float)
periods[1:] = 1.0 / np.maximum(freqs[1:], eps)
band_mask = (
np.isfinite(periods)
& (periods >= min_period)
& (periods <= max_period)
)
band_idx = np.flatnonzero(band_mask)
cycle_period = np.full(n_rows, float(default_period), dtype=float)
raw_period = np.full(n_rows, np.nan, dtype=float)
peak_ratio = np.full(n_rows, np.nan, dtype=float)
dominance = np.full(n_rows, np.nan, dtype=float)
entropy = np.full(n_rows, np.nan, dtype=float)
valid = np.zeros(n_rows, dtype=bool)
if len(band_idx) < 3 or n_rows == 0:
return DataFrame(
{"cycle_period": cycle_period, "fft_raw_period": raw_period,
"fft_peak_ratio": peak_ratio, "fft_dominance": dominance,
"fft_entropy": entropy, "fft_valid": valid},
index=index,
)
alpha_period = 2.0 / (float(smooth_span) + 1.0)
prev_period = float(default_period)
for i in range(L - 1, n_rows):
window = values[i - L + 1: i + 1]
if (
len(window) != L
or not np.all(np.isfinite(window))
or np.any(window <= 0)
):
cycle_period[i] = prev_period
continue
x = np.log(np.clip(window, eps, None))
x = self._linear_detrend_window(x)
z = self._robust_zscore_window(x, eps=eps)
y = z * hann
fft_out = np.fft.rfft(y, n=n_fft)
power = (np.abs(fft_out) ** 2) / (L * window_energy + eps)
if len(power) > 2:
if n_fft % 2 == 0:
power[1:-1] *= 2.0
else:
power[1:] *= 2.0
power = np.nan_to_num(power, nan=0.0, posinf=0.0, neginf=0.0)
band_power = power[band_idx]
total_band_power = float(np.sum(band_power))
if total_band_power <= eps:
cycle_period[i] = prev_period
continue
local_peak_pos = int(np.argmax(band_power))
k_peak = int(band_idx[local_peak_pos])
peak_power = float(power[k_peak])
median_power = float(np.median(band_power))
pr = peak_power / (median_power + eps)
dom = peak_power / (total_band_power + eps)
prob = band_power / (total_band_power + eps)
ent = -float(np.sum(prob * np.log(prob + eps))) / np.log(len(prob))
peak_ratio[i] = pr
dominance[i] = dom
entropy[i] = ent
is_valid = (
pr >= peak_ratio_min
and dom >= dominance_min
and ent <= entropy_max
)
if is_valid:
k_hat = float(k_peak)
if 1 <= k_peak < len(power) - 1:
left = np.log(power[k_peak - 1] + eps)
center = np.log(power[k_peak] + eps)
right = np.log(power[k_peak + 1] + eps)
denom = left - 2.0 * center + right
if abs(denom) > eps:
delta = 0.5 * (left - right) / denom
delta = float(np.clip(delta, -0.5, 0.5))
k_hat = float(k_peak) + delta
detected_period = n_fft / max(k_hat, eps)
detected_period = float(np.clip(detected_period, min_period, max_period))
raw_period[i] = detected_period
valid[i] = True
smoothed = alpha_period * detected_period + (1.0 - alpha_period) * prev_period
prev_period = float(np.clip(smoothed, min_period, max_period))
else:
prev_period = float(np.clip(prev_period, min_period, max_period))
cycle_period[i] = prev_period
cycle_period = np.nan_to_num(
cycle_period,
nan=float(default_period),
posinf=float(default_period),
neginf=float(default_period),
)
return DataFrame(
{"cycle_period": cycle_period, "fft_raw_period": raw_period,
"fft_peak_ratio": peak_ratio, "fft_dominance": dominance,
"fft_entropy": entropy, "fft_valid": valid},
index=index,
)
# =========================================================================
# KALMAN FILTER — stabilises detected period, eliminates float
# =========================================================================
def _kalman_smooth_period(
self,
raw_period: np.ndarray,
valid_mask: np.ndarray,
default_period: float,
min_p: float,
max_p: float,
) -> np.ndarray:
"""
Scalar Kalman filter treating each valid FFT detection as a noisy
measurement of the true cycle period. Between detections the estimate
drifts at process-noise rate Q — slow enough to be stable, fast enough
to track genuine regime shifts.
"""
n = len(raw_period)
smoothed = np.empty(n, dtype=float)
x = default_period
P = 100.0
Q = self.kalman_q
R = self.kalman_r
for i in range(n):
# Predict
P = P + Q
# Update only when FFT produced a valid detection
if valid_mask[i] and np.isfinite(raw_period[i]):
K = P / (P + R)
x = x + K * (raw_period[i] - x)
P = (1.0 - K) * P
x = float(np.clip(x, min_p, max_p))
smoothed[i] = x
return smoothed
# =========================================================================
# ADAPTIVE BIQUAD BANDPASS — center tracks Kalman period
# =========================================================================
def _adaptive_bandpass(
self,
price: np.ndarray,
period: np.ndarray,
bw: float = 0.3,
) -> np.ndarray:
"""
Two-pole biquad bandpass (digital, standard form):
omega = 2π / P[i]
alpha = sin(omega) * bw / 2 (controls bandwidth; Q = 1/bw)
BP[i] = (alpha/(1+alpha)) * (x[i] - x[i-2])
+ (2·cos(omega)/(1+alpha)) * BP[i-1]
- ((1-alpha)/(1+alpha)) * BP[i-2]
Coefficients are recomputed every bar so the filter adapts continuously
as the Kalman period estimate evolves.
"""
n = len(price)
bp = np.zeros(n, dtype=float)
eps = 1e-12
for i in range(2, n):
P_i = float(max(period[i], 3.0))
omega = 2.0 * math.pi / P_i
sin_w = math.sin(omega)
cos_w = math.cos(omega)
alpha = sin_w * bw / 2.0
denom = 1.0 + alpha
if abs(denom) < eps:
continue
b0 = alpha / denom # coefficient for (x[i] - x[i-2])
a1 = -2.0 * cos_w / denom # coefficient for -BP[i-1] (note sign)
a2 = (1.0 - alpha) / denom # coefficient for -BP[i-2]
bp[i] = b0 * (price[i] - price[i - 2]) - a1 * bp[i - 1] - a2 * bp[i - 2]
return bp
# =========================================================================
# CYCLE OSCILLATOR — normalised bandpass (bp_z)
# =========================================================================
@staticmethod
def _cycle_oscillator(bp: np.ndarray, window: int = 50) -> np.ndarray:
"""
Normalise BP by its rolling RMS so the oscillator is unit-free and
comparable across pairs and time. Values near ±1 = typical extremes;
±2 = strong extremes. Clipped to [-3, 3] to remove spikes.
"""
bp_s = pd.Series(bp)
min_p = max(5, window // 4)
rms = bp_s.pow(2).rolling(window, min_periods=min_p).mean().pow(0.5)
osc = bp_s / (rms + 1e-12)
return np.clip(osc.fillna(0.0).to_numpy(dtype=float), -3.0, 3.0)
# =========================================================================
# EXIT STATE — in-memory primary, DB for restart recovery
# =========================================================================
def _load_exit_state(self, trade) -> dict:
tid = trade.id
if tid in self._exit_states:
return dict(self._exit_states[tid])
data = trade.get_custom_data(self._EXIT_STATE_KEY)
if data:
self._exit_states[tid] = dict(data)
return dict(data)
return {"tp_activated": False, "max_profit": None}
def _save_exit_state(self, trade, data: dict) -> None:
self._exit_states[trade.id] = dict(data)
try:
trade.set_custom_data(self._EXIT_STATE_KEY, data)
except Exception:
pass
# =========================================================================
# INDICATORS
# =========================================================================
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata.get("pair", "?")
logger.debug(f"[FFT] {pair}: computing indicators ({len(dataframe)} bars)")
# ── 1. FFT cycle period ──────────────────────────────────────────────
fft_df = self.causal_fft_cycle_period(
price = dataframe["close"],
window_size = self.fft_window,
min_period = self.fft_min_period,
max_period = self.fft_max_period,
default_period = self.fft_default_period,
smooth_span = self.fft_smooth_span,
peak_ratio_min = self.fft_peak_ratio_min,
dominance_min = self.min_cycle_dominance,
entropy_max = self.fft_entropy_max,
)
dataframe["fft_cycle_period"] = fft_df["cycle_period"].values
dataframe["fft_raw_period"] = fft_df["fft_raw_period"].values
dataframe["fft_dominance"] = fft_df["fft_dominance"].values
dataframe["fft_entropy"] = fft_df["fft_entropy"].values
dataframe["fft_peak_ratio"] = fft_df["fft_peak_ratio"].values
dataframe["fft_valid"] = fft_df["fft_valid"].values
# Forward-fill dominance so quality gate doesn't flicker on NaN bars
dataframe["fft_dom_ffill"] = (
dataframe["fft_dominance"]
.ffill()
.fillna(0.0)
)
# ── 2. Kalman-smoothed period ────────────────────────────────────────
k_period = self._kalman_smooth_period(
raw_period = fft_df["fft_raw_period"].values,
valid_mask = fft_df["fft_valid"].values.astype(bool),
default_period = float(self.fft_default_period),
min_p = float(self.fft_min_period),
max_p = float(self.fft_max_period),
)
dataframe["kalman_period"] = k_period
# ── 3. Adaptive bandpass ─────────────────────────────────────────────
close_arr = dataframe["close"].to_numpy(dtype=float)
bp_arr = self._adaptive_bandpass(close_arr, k_period, bw=self.bp_bandwidth)
dataframe["bp"] = bp_arr
# ── 4. Cycle oscillator ──────────────────────────────────────────────
dataframe["bp_z"] = self._cycle_oscillator(bp_arr, window=self.bp_z_window)
# ── 5. Cycle completion oscillator (Ehlers-style combined) ───────────
# Raw bandpass normalised by 3-bar EMA of its absolute value — slightly
# smoother than bp_z for exit signals, avoiding micro-oscillations.
bp_s = pd.Series(bp_arr)
bp_abs_ema = bp_s.abs().ewm(span=3, min_periods=1).mean()
dataframe["bp_norm"] = (bp_s / (bp_abs_ema + 1e-12)).clip(-3, 3).values
# ── 6. EMA-200 macro trend ───────────────────────────────────────────
dataframe["ema200"] = ta.EMA(dataframe, timeperiod=self.trend_ema_period)
ema_shifted = dataframe["ema200"].shift(self.trend_slope_bars)
dataframe["ema200_slope"] = dataframe["ema200"] - ema_shifted
dataframe["trend_up"] = dataframe["ema200_slope"] > 0
dataframe["trend_down"] = dataframe["ema200_slope"] < 0
# ── 7. ATR + ADX (context) ───────────────────────────────────────────
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
# ── 8. Plot threshold reference lines ─────────────────────────────────
dataframe["bp_z_upper"] = self.bp_z_entry # entry threshold (+)
dataframe["bp_z_lower"] = -self.bp_z_entry # entry threshold (-)
dataframe["bp_z_zero"] = 0.0 # midline
n_valid = int(fft_df["fft_valid"].sum())
logger.info(
f"[FFT] {pair}: {n_valid}/{len(dataframe)} valid detections, "
f"last Kalman period = {k_period[-1]:.1f} bars"
)
return dataframe
# =========================================================================
# PLOT CONFIG
# =========================================================================
plot_config = {
"main_plot": {
"ema200": {"color": "orange", "width": 2},
},
"subplots": {
"Cycle Oscillator": {
"bp_z": {"color": "#2196F3", "width": 2},
"bp_norm": {"color": "#90CAF9", "opacity": 0.5},
"bp_z_upper":{"color": "#EF5350", "opacity": 0.4},
"bp_z_lower":{"color": "#66BB6A", "opacity": 0.4},
"bp_z_zero": {"color": "#777777", "opacity": 0.3},
},
"Kalman Period": {
"kalman_period": {"color": "#4CAF50", "width": 2},
"fft_cycle_period": {"color": "#A5D6A7", "opacity": 0.6},
"fft_raw_period": {"color": "#C8E6C9", "opacity": 0.3},
},
"FFT Quality": {
"fft_dominance": {"color": "#9C27B0", "width": 2},
"fft_entropy": {"color": "#CE93D8", "opacity": 0.6},
"fft_peak_ratio": {"color": "#7B1FA2", "opacity": 0.4},
},
"Trend": {
"ema200_slope": {"color": "#FF9800", "width": 2},
"adx": {"color": "#607D8B", "opacity": 0.7},
},
"Bandpass": {
"bp": {"color": "#00BCD4", "opacity": 0.8},
"bp_norm":{"color": "#80DEEA", "opacity": 0.5},
},
},
}
# =========================================================================
# ENTRY — crossing-based triggers (fire once per trough / peak)
# =========================================================================
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
direction = str(getattr(self, "market_direction", None) or "none").lower()
allow_long = direction in ("long", "none")
allow_short = direction in ("short", "none")
# Cycle quality gate — require a meaningful dominant cycle
quality = dataframe["fft_dom_ffill"] >= self.min_cycle_dominance
# Trend conditions
if self.use_trend_filter:
trend_long = dataframe["trend_up"]
trend_short = dataframe["trend_down"]
else:
trend_long = pd.Series(True, index=dataframe.index)
trend_short = pd.Series(True, index=dataframe.index)
bpz = dataframe["bp_z"]
bpz_prev = bpz.shift(1)
bpz_prev2 = bpz.shift(2)
# Long: oscillator just crossed UP through -bp_z_entry from below.
# Two consecutive bars below threshold confirms we were in the trough.
long_cross = (
(bpz > -self.bp_z_entry) # current bar above threshold (exiting trough)
& (bpz_prev <= -self.bp_z_entry) # previous bar was at/below threshold
& (bpz_prev2 <= -self.bp_z_entry) # confirmed: two bars in trough zone
)
# Short: oscillator just crossed DOWN through +bp_z_entry from above.
short_cross = (
(bpz < self.bp_z_entry)
& (bpz_prev >= self.bp_z_entry)
& (bpz_prev2 >= self.bp_z_entry)
)
if allow_long:
dataframe.loc[
long_cross & trend_long & quality & (dataframe["volume"] > 0),
["enter_long", "enter_tag"],
] = (1, "fft_long")
if allow_short:
dataframe.loc[
short_cross & trend_short & quality & (dataframe["volume"] > 0),
["enter_short", "enter_tag"],
] = (1, "fft_short")
return dataframe
# =========================================================================
# EXIT — handled entirely by custom_exit (trailing TP + force-by-days)
# =========================================================================
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
return dataframe
# =========================================================================
# DCA
# =========================================================================
def adjust_trade_position(
self,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
min_stake: Optional[float],
max_stake: float,
**kwargs: Any,
) -> Optional[Tuple[float, str]]:
dca_count = trade.nr_of_successful_entries - 1
if dca_count >= self.safety_order_max_count:
return None
if trade.open_orders:
return None
deviation = self.price_deviation_initial * (dca_count + 1)
if not trade.is_short:
if current_rate > trade.open_rate * (1 - deviation):
return None
else:
if current_rate < trade.open_rate * (1 + deviation):
return None
filled_orders = trade.select_filled_orders(trade.entry_side)
if not filled_orders:
return None
first_cost = filled_orders[0].cost
so_amount = first_cost * self.safety_order_ratio * (
self.safety_order_volume_scale ** dca_count
)
if min_stake is not None and so_amount < min_stake:
so_amount = min_stake
so_amount = min(so_amount, max_stake)
tag = f"DCA_{dca_count + 1}"
logger.info(
f"🔄 {tag} {trade.pair} ({'S' if trade.is_short else 'L'}): "
f"{so_amount:.2f} USDT dev={deviation:.1%} "
f"trigger={trade.open_rate * (1 - deviation if not trade.is_short else -(1 - deviation)):.6f}"
)
return so_amount, tag
# =========================================================================
# CUSTOM EXIT — trailing TP tuned for 2× leverage
# =========================================================================
def custom_exit(
self,
pair: str,
trade: Trade,
current_time: datetime,
current_rate: float,
current_profit: float,
**kwargs: Any,
) -> Optional[str]:
# Force-close stalled but profitable trades
if self.force_exit_by_days_enabled and self.force_exit_after_days > 0:
age_days = (current_time - trade.open_date_utc).total_seconds() / 86400.0
if age_days >= self.force_exit_after_days and current_profit > 0:
logger.info(
f"⏰ Force exit {pair}: age={age_days:.2f}d "
f"profit={current_profit:.2%}"
)
self._save_exit_state(trade, {"tp_activated": False, "max_profit": None})
return "force_exit_max_days"
data = self._load_exit_state(trade)
# Initialise high-water mark on first call
if data["max_profit"] is None:
data["max_profit"] = current_profit
self._save_exit_state(trade, data)
# Fixed TP mode (no trailing)
if not self.trailing_after_tp:
if current_profit >= self.take_profit:
self._save_exit_state(trade, {"tp_activated": False, "max_profit": None})
return "take_profit"
return None
# Arm trailing when profit exceeds take_profit + activation buffer
if not data["tp_activated"]:
if current_profit >= self.take_profit + self.trailing_min_activation:
data["tp_activated"] = True
data["max_profit"] = current_profit
self._save_exit_state(trade, data)
logger.info(f"🎯 Trailing armed {pair}: {current_profit:.2%}")
return None
# Update high-water mark
if current_profit > data["max_profit"]:
data["max_profit"] = current_profit
self._save_exit_state(trade, data)
return None
# Exit on retrace from peak
retrace = data["max_profit"] - current_profit
if retrace >= self.trailing_retrace:
self._save_exit_state(trade, {"tp_activated": False, "max_profit": None})
logger.info(
f"📉 Trailing exit {pair}: "
f"peak={data['max_profit']:.2%} retrace={retrace:.2%}"
)
return "trailing_take_profit"
return None
# =========================================================================
# LEVERAGE
# =========================================================================
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 2.0