自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
493 lines
18 KiB
Python
493 lines
18 KiB
Python
# --- 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)
|