# --- Do not remove these libs --- """ Wyckoff BTC — Branch B: LPS Trend Continuation(独立 Setup 研究) Status: RESEARCH Spring V1: BASELINE FROZEN(禁止改动 / 禁止与本分支合并调参) LPS V2 假设(验证中): 4h 原生 SOS Confirm → 1h LPS Entry 不是 1h 假突破回踩 4h SOS: ① close > range_high(实体收盘离开区间,非 wick) ② volume > MA20 * 1.5 ③ close strength (close-low)/(high-low) > 0.7 ④ 随后 3 根 4h close 仍 > breakout_level 1h LPS: 第一次回踩 breakout_level 回踩深度 0.5~1.5 ATR(1h) volume_4h < sos_break_volume 转强: close > previous high setup_type / enter_tag: LPS / LPSY regime_mode=trend(Range disabled) """ from freqtrade.strategy import ( IStrategy, IntParameter, DecimalParameter, CategoricalParameter, merge_informative_pair, stoploss_from_open, stoploss_from_absolute, ) from freqtrade.persistence import Trade import talib.abstract as ta from pandas import DataFrame import pandas as pd import numpy as np from datetime import datetime from typing import Optional import logging logger = logging.getLogger(__name__) # freqtrade backtesting -c ./user_data/Chan/config/Wyckoff_BTC_LPS.json \ # --strategy Wyckoff_BTC_LPS --strategy-path ./user_data/Chan/strategies --timerange=20230101- class Wyckoff_BTC_LPS(IStrategy): """LPS V2: 4h 原生 SOS → 1h LPS。不与 Spring 混用。""" INTERFACE_VERSION = 3 STRATEGY_VERSION = "LPS_V2" SETUP_FAMILY = "LPS" timeframe = "1h" structure_timeframe = "4h" bias_timeframe: Optional[str] = "8h" use_bias_filter = True regime_mode: str = "trend" can_short = True process_only_new_candles = True startup_candle_count = 220 minimal_roi = { "0": 0.12, "1440": 0.06, "4320": 0.03, "10080": 0, } stoploss = -0.10 use_custom_stoploss = True trailing_stop = True trailing_stop_positive = 0.025 trailing_stop_positive_offset = 0.05 trailing_only_offset_is_reached = True use_exit_signal = True exit_profit_only = False # ---- 固定规则(不做 hyperopt)---- range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False) sos_vol_mult = DecimalParameter(1.2, 2.5, default=1.5, decimals=1, space="buy", optimize=False) sos_close_strength = DecimalParameter(0.55, 0.90, default=0.70, decimals=2, space="buy", optimize=False) sos_hold_bars_4h = IntParameter(1, 6, default=3, space="buy", optimize=False) lps_pb_atr_min = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=False) lps_pb_atr_max = DecimalParameter(1.0, 2.5, default=1.5, decimals=1, space="buy", optimize=False) lps_max_age_1h = IntParameter(12, 120, default=72, space="buy", optimize=False) atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False) atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False) atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False) time_stop_hours = IntParameter(48, 240, default=168, space="sell", optimize=False) use_lps_long = CategoricalParameter([True, False], default=True, space="buy", optimize=False) use_lps_short = CategoricalParameter([True, False], default=True, space="buy", optimize=False) lev = 1.0 def informative_pairs(self): pairs = self.dp.current_whitelist() if self.dp else [] tfs = {self.structure_timeframe} if self.bias_timeframe and self.use_bias_filter: tfs.add(self.bias_timeframe) return [(pair, tf) for pair in pairs for tf in tfs] def _add_bias_tf(self, df: DataFrame) -> DataFrame: df = df.copy() df["ema50"] = ta.EMA(df, timeperiod=50) df["ema200"] = ta.EMA(df, timeperiod=200) df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) return df def _add_sos_structure_4h(self, df: DataFrame) -> DataFrame: """在 4h 原生计算 SOS / SOW(含 hold 确认,无前视进场)。""" df = df.copy() lb = int(self.range_lookback.value) hold = int(self.sos_hold_bars_4h.value) vol_m = float(self.sos_vol_mult.value) strength_min = float(self.sos_close_strength.value) df["atr"] = ta.ATR(df, timeperiod=14) df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume") df["ema50"] = ta.EMA(df, timeperiod=50) df["ema200"] = ta.EMA(df, timeperiod=200) df["adx"] = ta.ADX(df, timeperiod=14) # 区间用「突破前」边界:shift(1) 的 rolling,避免当根抬高 df["range_high"] = df["high"].rolling(lb).max().shift(1) df["range_low"] = df["low"].rolling(lb).min().shift(1) bar_range = (df["high"] - df["low"]).replace(0, np.nan) df["close_strength"] = (df["close"] - df["low"]) / bar_range df["close_weakness"] = (df["high"] - df["close"]) / bar_range vol_ok = df["volume"] > df["volume_ma"] * vol_m # ① 实体收盘离开区间 ② 放量 ③ Effort Result sos_raw = ( df["range_high"].notna() & (df["close"] > df["range_high"]) & (df["close"].shift(1) <= df["range_high"]) & vol_ok & (df["close_strength"] > strength_min) ) sow_raw = ( df["range_low"].notna() & (df["close"] < df["range_low"]) & (df["close"].shift(1) >= df["range_low"]) & vol_ok & (df["close_weakness"] > strength_min) ) # 事件位:突破当根冻结 sos_level = df["range_high"].where(sos_raw) sos_vol = df["volume"].where(sos_raw) sos_origin = df["range_low"].where(sos_raw) sow_level = df["range_low"].where(sow_raw) sow_vol = df["volume"].where(sow_raw) sow_origin = df["range_high"].where(sow_raw) # ④ Hold:突破后 hold 根 4h 收盘仍在突破侧 → 在第 hold 根确认(无前视) sos_confirmed = sos_raw.shift(hold).fillna(False) sow_confirmed = sow_raw.shift(hold).fillna(False) for k in range(hold): sos_confirmed = sos_confirmed & (df["close"].shift(k) > sos_level.shift(hold)) sow_confirmed = sow_confirmed & (df["close"].shift(k) < sow_level.shift(hold)) # 确认当根带出冻结字段,再 ffill 供 1h 使用 df["sos_raw"] = sos_raw.fillna(False) df["sow_raw"] = sow_raw.fillna(False) df["sos_confirmed"] = sos_confirmed.fillna(False) df["sow_confirmed"] = sow_confirmed.fillna(False) df["sos_break_level"] = sos_level.shift(hold).where(df["sos_confirmed"]) df["sos_break_volume"] = sos_vol.shift(hold).where(df["sos_confirmed"]) df["sos_origin"] = sos_origin.shift(hold).where(df["sos_confirmed"]) df["sow_break_level"] = sow_level.shift(hold).where(df["sow_confirmed"]) df["sow_break_volume"] = sow_vol.shift(hold).where(df["sow_confirmed"]) df["sow_origin"] = sow_origin.shift(hold).where(df["sow_confirmed"]) df["sos_break_level"] = df["sos_break_level"].ffill() df["sos_break_volume"] = df["sos_break_volume"].ffill() df["sos_origin"] = df["sos_origin"].ffill() df["sow_break_level"] = df["sow_break_level"].ffill() df["sow_break_volume"] = df["sow_break_volume"].ffill() df["sow_origin"] = df["sow_origin"].ffill() df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"]) df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"]) return df @staticmethod def _bars_since(event: pd.Series) -> pd.Series: ev = event.fillna(False).astype(bool).to_numpy() out = np.full(len(ev), np.nan) c = np.nan for i, e in enumerate(ev): if e: c = 0.0 elif not np.isnan(c): c += 1.0 out[i] = c return pd.Series(out, index=event.index) @staticmethod def _expanding_max_since(event: pd.Series, value: pd.Series) -> pd.Series: """每个 event 之后对 value 做分段累计 max。""" ev = event.fillna(False).astype(bool).to_numpy() vals = value.to_numpy(dtype=float) out = np.full(len(ev), np.nan) cur = np.nan active = False for i in range(len(ev)): if ev[i]: active = True cur = vals[i] elif active: if not np.isnan(vals[i]): cur = vals[i] if np.isnan(cur) else max(cur, vals[i]) out[i] = cur if active else np.nan return pd.Series(out, index=event.index) @staticmethod def _expanding_min_since(event: pd.Series, value: pd.Series) -> pd.Series: ev = event.fillna(False).astype(bool).to_numpy() vals = value.to_numpy(dtype=float) out = np.full(len(ev), np.nan) cur = np.nan active = False for i in range(len(ev)): if ev[i]: active = True cur = vals[i] elif active: if not np.isnan(vals[i]): cur = vals[i] if np.isnan(cur) else min(cur, vals[i]) out[i] = cur if active else np.nan return pd.Series(out, index=event.index) def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: pair = metadata["pair"] stf = self.structure_timeframe btf = self.bias_timeframe inf4 = self.dp.get_pair_dataframe(pair=pair, timeframe=stf) inf4 = self._add_sos_structure_4h(inf4) keep4 = [ "date", "atr", "adx", "volume", "range_high", "range_low", "close_strength", "sos_raw", "sow_raw", "sos_confirmed", "sow_confirmed", "sos_break_level", "sos_break_volume", "sos_origin", "sow_break_level", "sow_break_volume", "sow_origin", "bull_bias", "bear_bias", ] inf4 = inf4[[c for c in keep4 if c in inf4.columns]].copy() dataframe = merge_informative_pair(dataframe, inf4, self.timeframe, stf, ffill=True) if btf and self.use_bias_filter and btf != stf: infb = self.dp.get_pair_dataframe(pair=pair, timeframe=btf) infb = self._add_bias_tf(infb) infb = infb[["date", "bull_bias", "bear_bias", "ema50", "ema200"]].copy() dataframe = merge_informative_pair(dataframe, infb, self.timeframe, btf, ffill=True) ss = f"_{stf}" bs = f"_{btf}" if btf and btf != stf else ss dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21) dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume") # 8h bias(优先);否则退回 4h bias if f"bull_bias{bs}" in dataframe.columns: bull = dataframe[f"bull_bias{bs}"].fillna(False).astype(bool) bear = dataframe[f"bear_bias{bs}"].fillna(False).astype(bool) else: bull = dataframe[f"bull_bias{ss}"].fillna(False).astype(bool) bear = dataframe[f"bear_bias{ss}"].fillna(False).astype(bool) dataframe["bias_long_ok"] = bull dataframe["bias_short_ok"] = bear sos_conf = dataframe[f"sos_confirmed{ss}"].fillna(False).astype(bool) sow_conf = dataframe[f"sow_confirmed{ss}"].fillna(False).astype(bool) # 确认沿上升沿:4h 确认映射到 1h 后的首次 True sos_event = sos_conf & ~sos_conf.shift(1).fillna(False) sow_event = sow_conf & ~sow_conf.shift(1).fillna(False) sos_level = dataframe[f"sos_break_level{ss}"] sos_bvol = dataframe[f"sos_break_volume{ss}"] sos_origin = dataframe[f"sos_origin{ss}"] sow_level = dataframe[f"sow_break_level{ss}"] sow_bvol = dataframe[f"sow_break_volume{ss}"] sow_origin = dataframe[f"sow_origin{ss}"] vol4 = dataframe[f"volume{ss}"] sos_age = self._bars_since(sos_event) sow_age = self._bars_since(sow_event) post_high = self._expanding_max_since(sos_event, dataframe["high"]) post_low = self._expanding_min_since(sow_event, dataframe["low"]) atr = dataframe["atr"] pb_min = float(self.lps_pb_atr_min.value) pb_max = float(self.lps_pb_atr_max.value) max_age = float(self.lps_max_age_1h.value) # 回踩深度:SOS 后高点回撤的 ATR 倍数 retrace_long = (post_high - dataframe["low"]) / atr.replace(0, np.nan) retrace_short = (dataframe["high"] - post_low) / atr.replace(0, np.nan) near_sos = dataframe["low"] <= (sos_level + atr * 0.35) near_sow = dataframe["high"] >= (sow_level - atr * 0.35) vol_dry_long = vol4 < sos_bvol vol_dry_short = vol4 < sow_bvol reclaim_long = dataframe["close"] > dataframe["high"].shift(1) reclaim_short = dataframe["close"] < dataframe["low"].shift(1) first_near_long = near_sos & ~near_sos.shift(1).fillna(False) first_near_short = near_sow & ~near_sow.shift(1).fillna(False) alive_long = ( sos_age.notna() & (sos_age >= 1) & (sos_age <= max_age) & (dataframe["close"] > sos_origin) ) alive_short = ( sow_age.notna() & (sow_age >= 1) & (sow_age <= max_age) & (dataframe["close"] < sow_origin) ) dataframe["lps"] = ( alive_long & first_near_long & retrace_long.between(pb_min, pb_max) & (dataframe["low"] > sos_origin) & (dataframe["close"] >= sos_level * 0.995) & vol_dry_long & reclaim_long & dataframe["bias_long_ok"] ) dataframe["lpsy"] = ( alive_short & first_near_short & retrace_short.between(pb_min, pb_max) & (dataframe["high"] < sow_origin) & (dataframe["close"] <= sow_level * 1.005) & vol_dry_short & reclaim_short & dataframe["bias_short_ok"] ) dataframe["sos"] = sos_event dataframe["sow"] = sow_event dataframe["sos_level"] = sos_level dataframe["sos_origin"] = sos_origin dataframe["sow_level"] = sow_level dataframe["sow_origin"] = sow_origin dataframe["sos_age"] = sos_age dataframe["sow_age"] = sow_age for col in ["lps", "lpsy", "bias_long_ok", "bias_short_ok", "sos", "sow"]: dataframe[col] = dataframe[col].fillna(False).astype(bool) dataframe["setup_type"] = "" dataframe.loc[dataframe["lps"], "setup_type"] = "LPS" dataframe.loc[dataframe["lpsy"], "setup_type"] = "LPSY" return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe["enter_long"] = 0 dataframe["enter_short"] = 0 dataframe["enter_tag"] = "" vol_ok = dataframe["volume"] > 0 if bool(self.use_lps_long.value): cond = vol_ok & dataframe["lps"] dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "LPS") if bool(self.use_lps_short.value): cond = vol_ok & dataframe["lpsy"] dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "LPSY") self._apply_regime_filter(dataframe) return dataframe def _apply_regime_filter(self, dataframe: DataFrame) -> None: rm = getattr(self, "regime_mode", "all") if rm == "all" or not self.bias_timeframe: return bs = f"_{self.bias_timeframe}" bc, ec = f"bull_bias{bs}", f"bear_bias{bs}" if bc not in dataframe.columns or ec not in dataframe.columns: return bull = dataframe[bc].fillna(False).astype(bool) bear = dataframe[ec].fillna(False).astype(bool) both = bull & bear bull, bear = bull & ~both, bear & ~both range_m = (~bull) & (~bear) if rm == "bull": mask = ~bull elif rm == "bear": mask = ~bear elif rm == "range": mask = ~range_m elif rm == "trend": mask = range_m else: return dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0) dataframe.loc[mask, "enter_tag"] = "" def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe["exit_long"] = 0 dataframe["exit_short"] = 0 dataframe["exit_tag"] = "" # 结构失效:收盘跌破 SOS 突破位 / 升破 SOW 突破位 exit_long = ( dataframe["sos_level"].notna() & (dataframe["close"] < dataframe["sos_level"]) & (dataframe["close"] < dataframe["ema21"]) ) | dataframe["sow"] exit_short = ( dataframe["sow_level"].notna() & (dataframe["close"] > dataframe["sow_level"]) & (dataframe["close"] > dataframe["ema21"]) ) | dataframe["sos"] dataframe.loc[exit_long.fillna(False), ["exit_long", "exit_tag"]] = (1, "lps_structure_fail") dataframe.loc[exit_short.fillna(False), ["exit_short", "exit_tag"]] = (1, "lps_structure_fail") return dataframe def custom_stoploss( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs, ) -> Optional[float]: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if dataframe.empty: return None last = dataframe.iloc[-1] atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0 if atr <= 0 or trade.open_rate <= 0: return None atr_dist = float(self.atr_sl_mult.value) * atr tag = trade.enter_tag or "" buffer = atr * 0.15 if after_fill and trade.get_custom_data("struct_stop") is None: if tag == "LPS" and pd.notna(last.get("sos_origin")): trade.set_custom_data("struct_stop", float(last["sos_origin"]) - buffer) elif tag == "LPSY" and pd.notna(last.get("sow_origin")): trade.set_custom_data("struct_stop", float(last["sow_origin"]) + buffer) elif trade.is_short: trade.set_custom_data("struct_stop", float(last["high"]) + buffer) else: trade.set_custom_data("struct_stop", float(last["low"]) - buffer) struct = trade.get_custom_data("struct_stop") if trade.is_short: atr_stop = trade.open_rate + atr_dist stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop else: atr_stop = trade.open_rate - atr_dist stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop raw = abs(trade.open_rate - stop_price) / trade.open_rate raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value)) if struct is not None and tag in ("LPS", "LPSY"): sl = stoploss_from_absolute( stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage ) return sl if sl and sl > 0 else None return stoploss_from_open( -raw, current_profit, is_short=trade.is_short, leverage=trade.leverage ) or None def custom_exit( self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs, ) -> Optional[str]: hours = (current_time - trade.open_date_utc).total_seconds() / 3600 if hours > float(self.time_stop_hours.value) and current_profit < 0: return "wyckoff_time_stop" if hours > float(self.time_stop_hours.value) * 2: return "wyckoff_time_stop_max" return None 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 min(self.lev, max_leverage)