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,449 @@
|
||||
# --- Do not remove these libs ---
|
||||
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter, 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 trade -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/Turtle_BTC.json --strategy Turtle_BTC --strategy-path ./user_data/Chan/strategies --timerange=20251201-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/Turtle_BTC.json -t 15m --pairs BTC/USDT:USDT --timerange=20240101-
|
||||
|
||||
|
||||
class Turtle_BTC(IStrategy):
|
||||
"""
|
||||
海龟交易法 (Turtle Trading) - 15m 优化版
|
||||
|
||||
相对经典日线参数,15m 上做了适配:
|
||||
- 通道周期拉长(约 1日 / 2日),降低噪音假突破
|
||||
- EMA200 趋势过滤:只做顺势方向
|
||||
- ADX 过滤:只在有趋势时开仓
|
||||
- 突破用「向上/向下穿越」,避免通道内反复信号
|
||||
- 单单元保证金上限,避免低波动时仓位占满账户
|
||||
- 系统2 优先、系统1 补漏(S1 带赢利跳过过滤)
|
||||
- trade_side 可限制只做多/只做空(默认 short,适配近段下跌市)
|
||||
"""
|
||||
INTERFACE_VERSION = 3
|
||||
timeframe = "15m"
|
||||
can_short = True
|
||||
process_only_new_candles = True
|
||||
# 需覆盖 S2 入场周期 + EMA200
|
||||
startup_candle_count = 250
|
||||
|
||||
minimal_roi = {"0": 100}
|
||||
stoploss = -0.99
|
||||
use_custom_stoploss = True
|
||||
trailing_stop = False
|
||||
use_exit_signal = False
|
||||
exit_profit_only = False
|
||||
ignore_roi_if_entry_signal = True
|
||||
|
||||
position_adjustment_enable = True
|
||||
max_entry_position_adjustment = 3 # 首仓 + 3 加仓 = 4 单元
|
||||
|
||||
# ---- 15m 适配后的默认周期(约 1日 / 2日)----
|
||||
# 96 根 15m ≈ 1 天;192 根 ≈ 2 天
|
||||
entry_period_s1 = IntParameter(48, 144, default=96, space="buy", optimize=True)
|
||||
exit_period_s1 = IntParameter(24, 96, default=48, space="sell", optimize=True)
|
||||
entry_period_s2 = IntParameter(120, 288, default=192, space="buy", optimize=True)
|
||||
exit_period_s2 = IntParameter(48, 144, default=96, space="sell", optimize=True)
|
||||
atr_period = IntParameter(14, 40, default=20, space="buy", optimize=False)
|
||||
stop_atr_mult = DecimalParameter(1.5, 3.5, default=2.0, decimals=1, space="sell", optimize=True)
|
||||
pyramid_atr_mult = DecimalParameter(0.3, 1.0, default=0.5, decimals=1, space="buy", optimize=True)
|
||||
risk_per_unit = DecimalParameter(0.005, 0.02, default=0.01, decimals=3, space="buy", optimize=False)
|
||||
adx_threshold = IntParameter(15, 35, default=20, space="buy", optimize=True)
|
||||
# 单单元保证金占可用资金上限(防止 15m 低波动时打满仓)
|
||||
max_unit_stake_pct = DecimalParameter(0.15, 0.40, default=0.25, decimals=2, space="buy", optimize=False)
|
||||
|
||||
lev = 1.0
|
||||
use_s1_win_skip = True
|
||||
use_system1 = True
|
||||
use_system2 = True
|
||||
# 趋势 / 强度过滤
|
||||
use_ema_filter = True
|
||||
use_adx_filter = True
|
||||
# None=双向;可用 "long" / "short" 限制单边(勿用单段行情曲线拟合)
|
||||
trade_side: Optional[str] = None
|
||||
ema_period = 200
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
ep1 = int(self.entry_period_s1.value)
|
||||
xp1 = int(self.exit_period_s1.value)
|
||||
ep2 = int(self.entry_period_s2.value)
|
||||
xp2 = int(self.exit_period_s2.value)
|
||||
atr_n = int(self.atr_period.value)
|
||||
|
||||
dataframe["atr"] = ta.ATR(dataframe, timeperiod=atr_n)
|
||||
dataframe["n"] = dataframe["atr"]
|
||||
dataframe["ema_trend"] = ta.EMA(dataframe, timeperiod=self.ema_period)
|
||||
dataframe["adx"] = ta.ADX(dataframe, timeperiod=14)
|
||||
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
|
||||
|
||||
# 唐奇安通道(shift 1 防 lookahead)
|
||||
dataframe["dc_high_s1"] = dataframe["high"].rolling(ep1).max().shift(1)
|
||||
dataframe["dc_low_s1"] = dataframe["low"].rolling(ep1).min().shift(1)
|
||||
dataframe["dc_exit_high_s1"] = dataframe["high"].rolling(xp1).max().shift(1)
|
||||
dataframe["dc_exit_low_s1"] = dataframe["low"].rolling(xp1).min().shift(1)
|
||||
|
||||
dataframe["dc_high_s2"] = dataframe["high"].rolling(ep2).max().shift(1)
|
||||
dataframe["dc_low_s2"] = dataframe["low"].rolling(ep2).min().shift(1)
|
||||
dataframe["dc_exit_high_s2"] = dataframe["high"].rolling(xp2).max().shift(1)
|
||||
dataframe["dc_exit_low_s2"] = dataframe["low"].rolling(xp2).min().shift(1)
|
||||
|
||||
# 穿越突破(只在刚突破那根触发)
|
||||
dataframe["break_up_s1"] = (
|
||||
(dataframe["close"] > dataframe["dc_high_s1"])
|
||||
& (dataframe["close"].shift(1) <= dataframe["dc_high_s1"].shift(1))
|
||||
)
|
||||
dataframe["break_dn_s1"] = (
|
||||
(dataframe["close"] < dataframe["dc_low_s1"])
|
||||
& (dataframe["close"].shift(1) >= dataframe["dc_low_s1"].shift(1))
|
||||
)
|
||||
dataframe["break_up_s2"] = (
|
||||
(dataframe["close"] > dataframe["dc_high_s2"])
|
||||
& (dataframe["close"].shift(1) <= dataframe["dc_high_s2"].shift(1))
|
||||
)
|
||||
dataframe["break_dn_s2"] = (
|
||||
(dataframe["close"] < dataframe["dc_low_s2"])
|
||||
& (dataframe["close"].shift(1) >= dataframe["dc_low_s2"].shift(1))
|
||||
)
|
||||
|
||||
# 顺势过滤:价格相对 EMA200
|
||||
dataframe["trend_long"] = dataframe["close"] > dataframe["ema_trend"]
|
||||
dataframe["trend_short"] = dataframe["close"] < dataframe["ema_trend"]
|
||||
dataframe["adx_ok"] = dataframe["adx"] >= float(self.adx_threshold.value)
|
||||
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * 0.8
|
||||
|
||||
if self.use_s1_win_skip:
|
||||
dataframe["skip_s1_long"] = self._s1_skip_mask(
|
||||
dataframe, long=True, exit_col="dc_exit_low_s1"
|
||||
)
|
||||
dataframe["skip_s1_short"] = self._s1_skip_mask(
|
||||
dataframe, long=False, exit_col="dc_exit_high_s1"
|
||||
)
|
||||
else:
|
||||
dataframe["skip_s1_long"] = False
|
||||
dataframe["skip_s1_short"] = False
|
||||
|
||||
return dataframe
|
||||
|
||||
@staticmethod
|
||||
def _s1_skip_mask(dataframe: DataFrame, long: bool, exit_col: str) -> pd.Series:
|
||||
"""系统1:上次同向突破盈利则跳过下一次。"""
|
||||
n = len(dataframe)
|
||||
skip = np.zeros(n, dtype=bool)
|
||||
in_trade = False
|
||||
entry_price = 0.0
|
||||
last_was_win = False
|
||||
closes = dataframe["close"].to_numpy()
|
||||
breaks = (dataframe["break_up_s1"] if long else dataframe["break_dn_s1"]).fillna(False).to_numpy()
|
||||
exits = dataframe[exit_col].to_numpy()
|
||||
|
||||
for i in range(n):
|
||||
if np.isnan(exits[i]) or np.isnan(closes[i]):
|
||||
continue
|
||||
if in_trade:
|
||||
hit_exit = closes[i] < exits[i] if long else closes[i] > exits[i]
|
||||
if hit_exit:
|
||||
pnl = (closes[i] - entry_price) if long else (entry_price - closes[i])
|
||||
last_was_win = pnl > 0
|
||||
in_trade = False
|
||||
elif breaks[i]:
|
||||
if last_was_win:
|
||||
skip[i] = True
|
||||
last_was_win = False
|
||||
else:
|
||||
in_trade = True
|
||||
entry_price = closes[i]
|
||||
return pd.Series(skip, index=dataframe.index)
|
||||
|
||||
def _entry_filters(self, dataframe: DataFrame, long: bool) -> pd.Series:
|
||||
base = (
|
||||
(dataframe["volume"] > 0)
|
||||
& dataframe["atr"].notna()
|
||||
& (dataframe["atr"] > 0)
|
||||
& dataframe["vol_ok"]
|
||||
)
|
||||
if self.use_ema_filter:
|
||||
base &= dataframe["trend_long"] if long else dataframe["trend_short"]
|
||||
if self.use_adx_filter:
|
||||
base &= dataframe["adx_ok"]
|
||||
return base
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["enter_long"] = 0
|
||||
dataframe["enter_short"] = 0
|
||||
dataframe["enter_tag"] = ""
|
||||
|
||||
allow_long = self.trade_side in (None, "long")
|
||||
allow_short = self.trade_side in (None, "short")
|
||||
long_base = self._entry_filters(dataframe, long=True) if allow_long else False
|
||||
short_base = self._entry_filters(dataframe, long=False) if allow_short else False
|
||||
|
||||
# 系统2优先(更稳),系统1补漏
|
||||
if self.use_system2:
|
||||
if allow_long:
|
||||
long_s2 = long_base & dataframe["break_up_s2"]
|
||||
dataframe.loc[long_s2, ["enter_long", "enter_tag"]] = (1, "turtle_s2_long")
|
||||
if allow_short:
|
||||
short_s2 = short_base & dataframe["break_dn_s2"]
|
||||
dataframe.loc[short_s2, ["enter_short", "enter_tag"]] = (1, "turtle_s2_short")
|
||||
|
||||
if self.use_system1:
|
||||
if allow_long:
|
||||
long_s1 = (
|
||||
long_base & dataframe["break_up_s1"]
|
||||
& (~dataframe["skip_s1_long"])
|
||||
& (dataframe["enter_long"] != 1)
|
||||
)
|
||||
dataframe.loc[long_s1, ["enter_long", "enter_tag"]] = (1, "turtle_s1_long")
|
||||
if allow_short:
|
||||
short_s1 = (
|
||||
short_base & dataframe["break_dn_s1"]
|
||||
& (~dataframe["skip_s1_short"])
|
||||
& (dataframe["enter_short"] != 1)
|
||||
)
|
||||
dataframe.loc[short_s1, ["enter_short", "enter_tag"]] = (1, "turtle_s1_short")
|
||||
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""按入场系统使用对应退出通道;用 close 与 current_rate 双确认。"""
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return None
|
||||
last = dataframe.iloc[-1]
|
||||
tag = trade.enter_tag or ""
|
||||
price = min(float(last["close"]), current_rate) if not trade.is_short else max(float(last["close"]), current_rate)
|
||||
|
||||
if trade.is_short:
|
||||
if "s1" in tag and price > float(last["dc_exit_high_s1"]):
|
||||
return "turtle_s1_exit"
|
||||
if "s2" in tag and price > float(last["dc_exit_high_s2"]):
|
||||
return "turtle_s2_exit"
|
||||
else:
|
||||
if "s1" in tag and price < float(last["dc_exit_low_s1"]):
|
||||
return "turtle_s1_exit"
|
||||
if "s2" in tag and price < float(last["dc_exit_low_s2"]):
|
||||
return "turtle_s2_exit"
|
||||
return None
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: Optional[float],
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return proposed_stake
|
||||
|
||||
last = dataframe.iloc[-1]
|
||||
atr = float(last["atr"]) if pd.notna(last["atr"]) else 0.0
|
||||
if atr <= 0 or current_rate <= 0:
|
||||
return proposed_stake
|
||||
|
||||
wallets = self.wallets
|
||||
available = wallets.get_total(self.config["stake_currency"]) if wallets else max_stake
|
||||
risk_amount = available * float(self.risk_per_unit.value)
|
||||
stop_dist = float(self.stop_atr_mult.value) * atr
|
||||
notional = risk_amount * current_rate / stop_dist
|
||||
stake = notional / max(leverage, 1.0)
|
||||
|
||||
# 单单元上限,避免低波动打满仓
|
||||
stake = min(stake, available * float(self.max_unit_stake_pct.value))
|
||||
|
||||
if min_stake is not None:
|
||||
stake = max(stake, min_stake)
|
||||
stake = min(stake, max_stake)
|
||||
return stake
|
||||
|
||||
def adjust_trade_position(
|
||||
self,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
min_stake: Optional[float],
|
||||
max_stake: float,
|
||||
current_entry_rate: float,
|
||||
current_exit_rate: float,
|
||||
current_entry_profit: float,
|
||||
current_exit_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
"""每朝有利方向 0.5N 加仓,最多 4 单元;有挂单时不加。"""
|
||||
if trade.has_open_orders:
|
||||
return None
|
||||
if trade.nr_of_successful_entries >= (1 + self.max_entry_position_adjustment):
|
||||
return None
|
||||
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.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:
|
||||
return None
|
||||
|
||||
entry_n = trade.get_custom_data("entry_n")
|
||||
if entry_n is None:
|
||||
entry_n = atr
|
||||
trade.set_custom_data("entry_n", entry_n)
|
||||
|
||||
last_entry_price = trade.get_custom_data("last_entry_price")
|
||||
if last_entry_price is None:
|
||||
last_entry_price = trade.open_rate
|
||||
trade.set_custom_data("last_entry_price", last_entry_price)
|
||||
|
||||
# 已规划的下一单元序号(从第 2 单元起)
|
||||
next_unit = trade.nr_of_successful_entries + 1
|
||||
step = float(self.pyramid_atr_mult.value) * float(entry_n)
|
||||
# 相对首仓(或记录的单元锚定价)计算阈值,避免 after_fill 用均价漂移
|
||||
anchor = float(trade.get_custom_data("unit1_price") or trade.open_rate)
|
||||
# 第 n 单元触发价 = 首仓 ± (n-1)*0.5N
|
||||
offset = (next_unit - 1) * step
|
||||
|
||||
if trade.is_short:
|
||||
trigger = anchor - offset
|
||||
if current_rate > trigger:
|
||||
return None
|
||||
else:
|
||||
trigger = anchor + offset
|
||||
if current_rate < trigger:
|
||||
return None
|
||||
|
||||
stake = self.custom_stake_amount(
|
||||
pair=trade.pair,
|
||||
current_time=current_time,
|
||||
current_rate=current_rate,
|
||||
proposed_stake=max_stake,
|
||||
min_stake=min_stake,
|
||||
max_stake=max_stake,
|
||||
leverage=trade.leverage,
|
||||
entry_tag=trade.enter_tag,
|
||||
side="short" if trade.is_short else "long",
|
||||
)
|
||||
if stake <= 0:
|
||||
return None
|
||||
|
||||
return stake, f"turtle_pyramid_{next_unit}"
|
||||
|
||||
def custom_stoploss(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
after_fill: bool,
|
||||
**kwargs,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
止损 = 最近一单元入场价 ± 2N。
|
||||
加仓后整体移到新单元的 2N(海龟原版)。
|
||||
"""
|
||||
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 after_fill:
|
||||
filled = trade.nr_of_successful_entries
|
||||
if filled <= 1:
|
||||
trade.set_custom_data("unit1_price", current_rate)
|
||||
trade.set_custom_data("last_entry_price", current_rate)
|
||||
if atr > 0:
|
||||
trade.set_custom_data("entry_n", atr)
|
||||
else:
|
||||
# 加仓:用本次成交价作为最新单元锚点
|
||||
trade.set_custom_data("last_entry_price", current_rate)
|
||||
|
||||
entry_n = trade.get_custom_data("entry_n")
|
||||
n = float(entry_n) if entry_n is not None else atr
|
||||
if n <= 0:
|
||||
return None
|
||||
|
||||
last_entry = trade.get_custom_data("last_entry_price") or trade.open_rate
|
||||
mult = float(self.stop_atr_mult.value)
|
||||
|
||||
if trade.is_short:
|
||||
stop_price = float(last_entry) + mult * n
|
||||
else:
|
||||
stop_price = float(last_entry) - mult * n
|
||||
|
||||
sl = stoploss_from_absolute(
|
||||
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
|
||||
)
|
||||
# 0 表示止损已在价格不利侧之外,保持不变
|
||||
return sl if sl > 0 else None
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe.empty:
|
||||
return False
|
||||
row = dataframe.iloc[-1]
|
||||
if pd.isna(row["atr"]) or row["atr"] <= 0:
|
||||
return False
|
||||
if self.trade_side is not None and side != self.trade_side:
|
||||
return False
|
||||
if self.use_ema_filter:
|
||||
if side == "long" and not bool(row["trend_long"]):
|
||||
return False
|
||||
if side == "short" and not bool(row["trend_short"]):
|
||||
return False
|
||||
if self.use_adx_filter and not bool(row["adx_ok"]):
|
||||
return False
|
||||
return True
|
||||
|
||||
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