Files
Chan/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py
T
jackyu66gitandCursor 8ee11317d3 fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:57:43 +08:00

493 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# --- 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=trendRange 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)