fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC V1.0 BASELINE — FROZEN
|
||||
|
||||
Status: BASELINE FROZEN
|
||||
Evidence: PASS (+ Limited Evidence, N=20)
|
||||
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
|
||||
Risk: small sample — 目标积累 N>=50 再谈规模
|
||||
|
||||
Branch A: Spring Reversal
|
||||
8h bias + 4h structure + 1h Spring/UTAD
|
||||
Range disabled(regime_mode=trend)
|
||||
ATR + 结构止损
|
||||
setup_type: SPRING / UTAD
|
||||
|
||||
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
|
||||
LPS 是独立 Setup 研究,禁止并入本文件调参。
|
||||
"""
|
||||
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_V1_BASELINE.json \
|
||||
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
|
||||
|
||||
|
||||
class Wyckoff_BTC_V1_BASELINE(IStrategy):
|
||||
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
|
||||
INTERFACE_VERSION = 3
|
||||
STRATEGY_VERSION = "V1.0_BASELINE"
|
||||
SETUP_FAMILY = "SPRING"
|
||||
|
||||
timeframe = "1h"
|
||||
structure_timeframe = "4h"
|
||||
bias_timeframe: Optional[str] = "8h"
|
||||
use_bias_filter = True
|
||||
# trend = bull|bear only(Range disabled — 理论一致性约束,非调参)
|
||||
regime_mode: str = "trend"
|
||||
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
startup_candle_count = 220
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.10,
|
||||
"1440": 0.05,
|
||||
"4320": 0.025,
|
||||
"10080": 0,
|
||||
}
|
||||
stoploss = -0.10
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = True
|
||||
trailing_stop_positive = 0.02
|
||||
trailing_stop_positive_offset = 0.04
|
||||
trailing_only_offset_is_reached = True
|
||||
use_exit_signal = True
|
||||
exit_profit_only = False
|
||||
|
||||
# ---- 冻结默认值(optimize=False)----
|
||||
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
|
||||
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
|
||||
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
|
||||
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
|
||||
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
|
||||
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, 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=120, space="sell", optimize=False)
|
||||
|
||||
# Branch A:仅 Spring / UTAD
|
||||
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
|
||||
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
|
||||
use_sow_sig = CategoricalParameter([True, False], default=False, 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_wyckoff_structure(self, df: DataFrame) -> DataFrame:
|
||||
lb = int(self.range_lookback.value)
|
||||
|
||||
df["atr"] = ta.ATR(df, timeperiod=14)
|
||||
df["ema50"] = ta.EMA(df, timeperiod=50)
|
||||
df["ema200"] = ta.EMA(df, timeperiod=200)
|
||||
df["adx"] = ta.ADX(df, timeperiod=14)
|
||||
df["rsi"] = ta.RSI(df, timeperiod=14)
|
||||
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
|
||||
|
||||
df["tr_high"] = df["high"].rolling(lb).max()
|
||||
df["tr_low"] = df["low"].rolling(lb).min()
|
||||
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
|
||||
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
|
||||
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
|
||||
|
||||
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
|
||||
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
|
||||
|
||||
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
|
||||
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
|
||||
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
|
||||
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
|
||||
|
||||
down_bar = df["close"] < df["open"]
|
||||
up_bar = df["close"] > df["open"]
|
||||
vol_down = np.where(down_bar, df["volume"], np.nan)
|
||||
vol_up = np.where(up_bar, df["volume"], np.nan)
|
||||
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
|
||||
df["effort_absorb"] = (
|
||||
df["vol_down_ma"].notna()
|
||||
& df["vol_up_ma"].notna()
|
||||
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
|
||||
)
|
||||
|
||||
df["accum_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_down"] | (df["close"] < df["ema50"]))
|
||||
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
df["distrib_ctx"] = (
|
||||
df["in_range"]
|
||||
& (df["prior_up"] | (df["close"] > df["ema50"]))
|
||||
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
|
||||
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
|
||||
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
return df
|
||||
|
||||
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
|
||||
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
|
||||
inf = self._add_wyckoff_structure(inf)
|
||||
keep = [
|
||||
"date", "atr", "ema50", "ema200", "adx", "rsi",
|
||||
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
|
||||
"in_range", "accum_ctx", "distrib_ctx",
|
||||
"vol_spike", "effort_absorb", "prior_down", "prior_up",
|
||||
"bull_bias", "bear_bias",
|
||||
]
|
||||
inf = inf[[c for c in keep if c in inf.columns]].copy()
|
||||
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
pair = metadata["pair"]
|
||||
stf = self.structure_timeframe
|
||||
dataframe = self._merge_tf(dataframe, pair, stf)
|
||||
|
||||
btf = self.bias_timeframe
|
||||
if btf and self.use_bias_filter and btf != stf:
|
||||
dataframe = self._merge_tf(dataframe, pair, btf)
|
||||
|
||||
ss = f"_{stf}"
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
|
||||
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
|
||||
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
|
||||
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
|
||||
|
||||
tr_high = dataframe[f"tr_high{ss}"]
|
||||
tr_low = dataframe[f"tr_low{ss}"]
|
||||
pierce = float(self.spring_pierce_pct.value)
|
||||
|
||||
accum_soft = (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
|
||||
)
|
||||
)
|
||||
distrib_soft = (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
| (
|
||||
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
|
||||
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
|
||||
)
|
||||
)
|
||||
|
||||
if btf and self.use_bias_filter:
|
||||
bs = f"_{btf}" if btf != stf else ss
|
||||
if f"bear_bias{bs}" in dataframe.columns:
|
||||
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
|
||||
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
else:
|
||||
dataframe["bias_long_ok"] = True
|
||||
dataframe["bias_short_ok"] = True
|
||||
|
||||
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
|
||||
|
||||
dataframe["spring"] = (
|
||||
tr_low.notna()
|
||||
& (dataframe["low"] < tr_low * (1.0 - pierce))
|
||||
& (dataframe["close"] > tr_low)
|
||||
& (dataframe["close"] > dataframe["open"])
|
||||
& accum_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] < 58)
|
||||
& dataframe["bias_long_ok"]
|
||||
)
|
||||
dataframe["utad"] = (
|
||||
tr_high.notna()
|
||||
& (dataframe["high"] > tr_high * (1.0 + pierce))
|
||||
& (dataframe["close"] < tr_high)
|
||||
& (dataframe["close"] < dataframe["open"])
|
||||
& distrib_soft
|
||||
& vol_mild
|
||||
& (dataframe["rsi"] > 42)
|
||||
& dataframe["bias_short_ok"]
|
||||
)
|
||||
# 基线不进 SOS/SOW;保留列供 exit 参考
|
||||
dataframe["sos"] = False
|
||||
dataframe["sow"] = False
|
||||
|
||||
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
|
||||
dataframe[col] = dataframe[col].fillna(False).astype(bool)
|
||||
dataframe["setup_type"] = ""
|
||||
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
|
||||
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
|
||||
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
|
||||
|
||||
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
|
||||
if bool(self.use_spring_sig.value):
|
||||
cond = vol_ok & dataframe["spring"]
|
||||
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
|
||||
|
||||
if bool(self.use_utad_sig.value):
|
||||
cond = vol_ok & dataframe["utad"]
|
||||
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
|
||||
|
||||
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 # Range disabled
|
||||
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"] = ""
|
||||
ss = f"_{self.structure_timeframe}"
|
||||
|
||||
exit_long = dataframe["utad"] | (
|
||||
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] < dataframe["ema21"])
|
||||
& (dataframe["rsi"] < 45)
|
||||
)
|
||||
exit_short = dataframe["spring"] | (
|
||||
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
|
||||
& (dataframe["close"] > dataframe["ema21"])
|
||||
& (dataframe["rsi"] > 55)
|
||||
)
|
||||
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
|
||||
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 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 (
|
||||
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
|
||||
):
|
||||
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)
|
||||
Reference in New Issue
Block a user