自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
489 lines
18 KiB
Python
489 lines
18 KiB
Python
"""
|
||
BTC Maker Scalper v1.1 — Liquidity Providing
|
||
|
||
相对 v1.0 的核心变化:
|
||
- 不再用 OBI/Delta/CVD 预测下一根涨跌(Directional Scalping)
|
||
- 改为:卖压衰竭 + Bid 吸收 → 提供流动性接单(Liquidity Providing)
|
||
- 挂单更深:Bid - 0~2 tick(等待被打)
|
||
- 出场:盘口/价差优势恢复(非固定 0.05% TP)
|
||
- 禁做市:5m EMA26 斜率过大 或 ATR 异常(单边趋势)
|
||
|
||
回测限制(仍然存在,但模型目标不同):
|
||
- OHLCV 无法完美模拟 Maker 成交时点;本版用更严过滤降频到 ~10-30 笔/天量级做压力测试
|
||
- 实盘用 orderbook 复核吸收/挂价
|
||
|
||
运行:
|
||
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper_v11.json \\
|
||
--strategy BTC_Maker_Micro_Scalper_v11 --strategy-path ./user_data/Chan/strategies \\
|
||
--timerange=20260701-20260708 --fee 0.00016 --enable-protections
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import talib.abstract as ta
|
||
from pandas import DataFrame
|
||
|
||
from freqtrade.persistence import Trade
|
||
from freqtrade.strategy import IStrategy
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _safe_div(num, den, fill=0.0):
|
||
out = np.where((den is not None) & (den != 0), num / den, fill)
|
||
return out
|
||
|
||
|
||
class BTC_Maker_Micro_Scalper_v11(IStrategy):
|
||
"""
|
||
v1.1 Liquidity Providing:卖压衰竭 + 吸收 → Maker 接单;趋势中禁做市。
|
||
"""
|
||
|
||
INTERFACE_VERSION: int = 3
|
||
timeframe = "1m"
|
||
can_short = True
|
||
process_only_new_candles = True
|
||
startup_candle_count = 200
|
||
|
||
# 不用固定小 ROI;出场交给 custom_exit(价差/优势恢复)
|
||
# 给一个很宽的 ROI 兜底,避免永远不走 ROI 路径也能被时间/恢复逻辑平掉
|
||
minimal_roi = {"0": 0.01}
|
||
# 硬止损仍保留,但比 v1 更宽松一点,避免“小止盈大止损”结构;主出场是恢复
|
||
stoploss = -0.0015 # -0.15% profit_ratio 硬止损(含杠杆后仍需观察)
|
||
trailing_stop = False
|
||
use_exit_signal = True
|
||
exit_profit_only = False
|
||
use_custom_stoploss = False
|
||
|
||
order_types = {
|
||
"entry": "limit",
|
||
"exit": "limit",
|
||
"stoploss": "limit",
|
||
"stoploss_on_exchange": False,
|
||
}
|
||
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
|
||
|
||
# ---- 费用 / 风控 ----
|
||
maker_fee = 0.00016
|
||
stake_pct = 0.005
|
||
max_leverage = 2.0 # v1.1 更克制
|
||
consecutive_loss_limit = 10
|
||
pause_minutes = 30
|
||
max_hold_minutes = 5
|
||
|
||
# ---- 微结构代理窗口(1m 近似 20s/100trades)----
|
||
sell_window = 3 # 近端卖量
|
||
sell_ref_window = 8 # 更长对比窗:必须“先有卖压再衰竭”
|
||
absorb_lookback = 5
|
||
min_absorb_ratio = 18.0 # 吸收要足够强(模型阈值,不是 OBI 调参)
|
||
tick_size = 0.1
|
||
maker_depth_ticks = 2 # Bid - 2 tick / Ask + 2 tick
|
||
exhaust_ratio = 0.70 # 近端卖量 < 参考窗 * 70%
|
||
prior_sell_mult = 1.2 # 衰竭前参考窗卖量须高于更长均量(真有过卖压)
|
||
|
||
# ---- 禁做市(趋势)----
|
||
ema_slope_thr = 0.00018 # 更早禁止单边做市
|
||
atr_spike_mult = 1.8
|
||
min_atr_pct = 0.00035
|
||
|
||
# 目标退出:相对入场的“优势恢复”幅度(价格)
|
||
edge_exit_pct = 0.00025
|
||
adverse_exit_pct = 0.0006
|
||
cooldown_minutes = 8 # 降频到验收带附近
|
||
|
||
ob_levels = 10
|
||
|
||
_loss_streak: int = 0
|
||
_pause_until: Optional[datetime] = None
|
||
_last_entry_time: Optional[datetime] = None
|
||
|
||
plot_config = {
|
||
"main_plot": {
|
||
"ema26_1m": {"color": "gray"},
|
||
},
|
||
"subplots": {
|
||
"SellVol": {
|
||
"sell_vol": {"color": "red"},
|
||
"sell_vol_ma": {"color": "orange"},
|
||
},
|
||
"Absorb": {"absorb_ratio": {"color": "blue"}},
|
||
"TrendBlock": {"trend_block": {"color": "black"}},
|
||
},
|
||
}
|
||
|
||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||
df = dataframe.copy()
|
||
high, low, close, volume = df["high"], df["low"], df["close"], df["volume"].astype(float)
|
||
|
||
close_c = close.clip(lower=low, upper=high)
|
||
hl = (high - low).astype(float)
|
||
hl_safe = hl.where(hl > 0, np.nan)
|
||
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
|
||
sell_frac = 1.0 - buy_frac
|
||
buy_vol = volume * buy_frac
|
||
sell_vol = volume * sell_frac
|
||
df["buy_vol"] = buy_vol
|
||
df["sell_vol"] = sell_vol
|
||
df["delta"] = buy_vol - sell_vol
|
||
|
||
# ---- A. 主动卖压衰竭(Long)----
|
||
# 先有卖压(ref 高),再衰竭(近端下降),且价格不创新低
|
||
df["sell_vol_ma"] = sell_vol.rolling(self.sell_window, min_periods=1).mean()
|
||
df["sell_vol_ref"] = sell_vol.rolling(self.sell_ref_window, min_periods=1).mean()
|
||
sell_baseline = sell_vol.rolling(30, min_periods=10).mean()
|
||
df["sell_exhaust"] = (
|
||
(df["sell_vol_ref"] > sell_baseline * self.prior_sell_mult)
|
||
& (df["sell_vol_ma"] < df["sell_vol_ref"] * self.exhaust_ratio)
|
||
& (low >= low.rolling(self.sell_ref_window, min_periods=1).min().shift(1))
|
||
)
|
||
|
||
# 主动买压衰竭(Short 对称)
|
||
df["buy_vol_ma"] = buy_vol.rolling(self.sell_window, min_periods=1).mean()
|
||
df["buy_vol_ref"] = buy_vol.rolling(self.sell_ref_window, min_periods=1).mean()
|
||
buy_baseline = buy_vol.rolling(30, min_periods=10).mean()
|
||
df["buy_exhaust"] = (
|
||
(df["buy_vol_ref"] > buy_baseline * self.prior_sell_mult)
|
||
& (df["buy_vol_ma"] < df["buy_vol_ref"] * self.exhaust_ratio)
|
||
& (high <= high.rolling(self.sell_ref_window, min_periods=1).max().shift(1))
|
||
)
|
||
|
||
# ---- B. Bid 吸收:成交卖量 / 价格跌幅 ----
|
||
# 价格跌幅用 lookback 内低点相对起点跌幅(百分比,避免除零)
|
||
px_drop = (close.shift(self.absorb_lookback) - low).clip(lower=0)
|
||
px_drop_pct = (px_drop / close.shift(self.absorb_lookback)).replace(0, np.nan)
|
||
sell_sum = sell_vol.rolling(self.absorb_lookback, min_periods=1).sum()
|
||
# absorb_ratio = 卖量 / (跌幅% * 10000) 标准化到可读量级;跌不动时放大
|
||
df["absorb_ratio"] = (sell_sum / (px_drop_pct * 10000.0)).replace(
|
||
[np.inf, -np.inf], np.nan
|
||
).fillna(0.0)
|
||
|
||
# 价格几乎不跌但有大量卖出 → 吸收极强:给高分
|
||
flat_sell = (px_drop_pct.fillna(0) < 0.00005) & (sell_sum > sell_sum.rolling(20).median())
|
||
df.loc[flat_sell.fillna(False), "absorb_ratio"] = df.loc[
|
||
flat_sell.fillna(False), "absorb_ratio"
|
||
].clip(lower=self.min_absorb_ratio * 1.5)
|
||
|
||
# Ask 吸收(Short):买量 / 上涨幅度
|
||
px_up = (high - close.shift(self.absorb_lookback)).clip(lower=0)
|
||
px_up_pct = (px_up / close.shift(self.absorb_lookback)).replace(0, np.nan)
|
||
buy_sum = buy_vol.rolling(self.absorb_lookback, min_periods=1).sum()
|
||
df["absorb_ratio_ask"] = (buy_sum / (px_up_pct * 10000.0)).replace(
|
||
[np.inf, -np.inf], np.nan
|
||
).fillna(0.0)
|
||
flat_buy = (px_up_pct.fillna(0) < 0.00005) & (buy_sum > buy_sum.rolling(20).median())
|
||
df.loc[flat_buy.fillna(False), "absorb_ratio_ask"] = df.loc[
|
||
flat_buy.fillna(False), "absorb_ratio_ask"
|
||
].clip(lower=self.min_absorb_ratio * 1.5)
|
||
|
||
df["bid_absorb"] = df["absorb_ratio"] >= self.min_absorb_ratio
|
||
df["ask_absorb"] = df["absorb_ratio_ask"] >= self.min_absorb_ratio
|
||
|
||
# ---- 波动与 ATR ----
|
||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||
df["atr_pct"] = (df["atr"] / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||
atr_med = df["atr_pct"].rolling(60, min_periods=20).median()
|
||
df["atr_spike"] = df["atr_pct"] > (atr_med * self.atr_spike_mult)
|
||
df["atr_ok"] = (df["atr_pct"] >= self.min_atr_pct) & (~df["atr_spike"])
|
||
|
||
# ---- 禁做市:趋势(EMA26 斜率,1m 上 5 根≈5m 变化代理)----
|
||
df["ema26_1m"] = ta.EMA(df, timeperiod=26)
|
||
df["ema26_slope"] = (
|
||
(df["ema26_1m"] - df["ema26_1m"].shift(5)) / close
|
||
).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||
df["trend_block"] = df["ema26_slope"].abs() > self.ema_slope_thr
|
||
|
||
# 微结构“可做市”综合
|
||
df["mm_regime"] = df["atr_ok"] & (~df["trend_block"])
|
||
|
||
# 中价 / 伪价差
|
||
df["mid"] = (high + low) / 2.0
|
||
df["range_pct"] = (hl / close).replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
||
|
||
return df
|
||
|
||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||
df = dataframe
|
||
|
||
# Long:卖压衰竭 + Bid 吸收 + 非趋势
|
||
long_cond = (
|
||
df["mm_regime"]
|
||
& df["sell_exhaust"]
|
||
& df["bid_absorb"]
|
||
& (df["volume"] > 0)
|
||
# 额外:近端 delta 不再恶化(卖压减弱)
|
||
& (df["delta"] > df["delta"].shift(1))
|
||
)
|
||
|
||
# Short:买压衰竭 + Ask 吸收 + 非趋势
|
||
short_cond = (
|
||
df["mm_regime"]
|
||
& df["buy_exhaust"]
|
||
& df["ask_absorb"]
|
||
& (df["volume"] > 0)
|
||
& (df["delta"] < df["delta"].shift(1))
|
||
)
|
||
|
||
df.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "lp_bid_absorb")
|
||
df.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "lp_ask_absorb")
|
||
return df
|
||
|
||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||
"""信号层只做趋势禁做市强平;主出场交给 custom_exit。"""
|
||
df = dataframe
|
||
df["exit_long"] = 0
|
||
df["exit_short"] = 0
|
||
df.loc[df["trend_block"], ["exit_long", "exit_tag"]] = (1, "trend_block")
|
||
df.loc[df["trend_block"], ["exit_short", "exit_tag"]] = (1, "trend_block")
|
||
return df
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Maker 报价:Bid - depth ticks / Ask + depth ticks
|
||
# ------------------------------------------------------------------ #
|
||
def custom_entry_price(
|
||
self,
|
||
pair: str,
|
||
trade: Trade | None,
|
||
current_time: datetime,
|
||
proposed_rate: float,
|
||
entry_tag: str | None,
|
||
side: str,
|
||
**kwargs,
|
||
) -> float:
|
||
offset = self.maker_depth_ticks * self.tick_size
|
||
try:
|
||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||
bids = ob.get("bids") or []
|
||
asks = ob.get("asks") or []
|
||
if side == "long" and bids:
|
||
return max(float(bids[0][0]) - offset, self.tick_size)
|
||
if side == "short" and asks:
|
||
return float(asks[0][0]) + offset
|
||
except Exception as e:
|
||
logger.debug("v11 entry price ob fallback: %s", e)
|
||
|
||
# 回测:挂得更深,降低“虚假即时成交”概率(仍不完美)
|
||
if side == "long":
|
||
return proposed_rate - offset
|
||
return proposed_rate + offset
|
||
|
||
def custom_exit_price(
|
||
self,
|
||
pair: str,
|
||
trade: Trade,
|
||
current_time: datetime,
|
||
proposed_rate: float,
|
||
current_profit: float,
|
||
exit_tag: str | None,
|
||
**kwargs,
|
||
) -> float:
|
||
offset = 1 * self.tick_size
|
||
try:
|
||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||
bids = ob.get("bids") or []
|
||
asks = ob.get("asks") or []
|
||
# 出场尽量 Maker:多头卖 Ask-1;空头买 Bid+1
|
||
if trade.is_short and bids:
|
||
return float(bids[0][0]) + offset
|
||
if (not trade.is_short) and asks:
|
||
return float(asks[0][0]) - offset
|
||
except Exception as e:
|
||
logger.debug("v11 exit price ob fallback: %s", e)
|
||
if trade.is_short:
|
||
return proposed_rate - offset
|
||
return proposed_rate + offset
|
||
|
||
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.max_leverage, float(max_leverage))
|
||
|
||
def custom_stake_amount(
|
||
self,
|
||
pair: str,
|
||
current_time: datetime,
|
||
current_rate: float,
|
||
proposed_stake: float,
|
||
min_stake: float | None,
|
||
max_stake: float,
|
||
leverage: float,
|
||
entry_tag: str | None,
|
||
side: str,
|
||
**kwargs,
|
||
) -> float:
|
||
try:
|
||
if self.wallets:
|
||
free = self.wallets.get_free(self.config["stake_currency"])
|
||
stake = free * self.stake_pct
|
||
if min_stake:
|
||
stake = max(stake, min_stake)
|
||
return min(stake, max_stake)
|
||
except Exception:
|
||
pass
|
||
return min(proposed_stake * self.stake_pct, max_stake) if proposed_stake else proposed_stake
|
||
|
||
def _paused(self, current_time: datetime) -> bool:
|
||
if self._pause_until is None:
|
||
return False
|
||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||
until = (
|
||
self._pause_until
|
||
if self._pause_until.tzinfo
|
||
else self._pause_until.replace(tzinfo=timezone.utc)
|
||
)
|
||
return now < until
|
||
|
||
def _in_cooldown(self, current_time: datetime) -> bool:
|
||
if self._last_entry_time is None:
|
||
return False
|
||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||
last = (
|
||
self._last_entry_time
|
||
if self._last_entry_time.tzinfo
|
||
else self._last_entry_time.replace(tzinfo=timezone.utc)
|
||
)
|
||
return (now - last) < timedelta(minutes=self.cooldown_minutes)
|
||
|
||
def confirm_trade_entry(
|
||
self,
|
||
pair: str,
|
||
order_type: str,
|
||
amount: float,
|
||
rate: float,
|
||
time_in_force: str,
|
||
current_time: datetime,
|
||
entry_tag: str | None,
|
||
side: str,
|
||
**kwargs,
|
||
) -> bool:
|
||
if self._paused(current_time) or self._in_cooldown(current_time):
|
||
return False
|
||
|
||
# 实盘:趋势禁做市 + 盘口复核(卖一/买一厚度)
|
||
try:
|
||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||
if dataframe is not None and len(dataframe):
|
||
last = dataframe.iloc[-1]
|
||
if bool(last.get("trend_block", False)) or (not bool(last.get("mm_regime", False))):
|
||
return False
|
||
|
||
if self.dp.runmode.value in ("live", "dry_run"):
|
||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||
bids = ob.get("bids") or []
|
||
asks = ob.get("asks") or []
|
||
if not bids or not asks:
|
||
return False
|
||
# 简单吸收代理:同价位附近挂单厚度
|
||
bid_vol = sum(float(b[1]) for b in bids[:3])
|
||
ask_vol = sum(float(a[1]) for a in asks[:3])
|
||
if side == "long" and bid_vol < ask_vol * 0.8:
|
||
# Bid 不够厚,吸收叙事弱
|
||
return False
|
||
if side == "short" and ask_vol < bid_vol * 0.8:
|
||
return False
|
||
except Exception as e:
|
||
logger.debug("v11 confirm entry: %s", e)
|
||
|
||
self._last_entry_time = current_time
|
||
return True
|
||
|
||
def custom_exit(
|
||
self,
|
||
pair: str,
|
||
trade: Trade,
|
||
current_time: datetime,
|
||
current_rate: float,
|
||
current_profit: float,
|
||
**kwargs,
|
||
):
|
||
open_time = trade.open_date_utc
|
||
if open_time.tzinfo is None:
|
||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||
if now - open_time >= timedelta(minutes=self.max_hold_minutes):
|
||
return "time_stop_5m"
|
||
|
||
# 优势恢复出场(替代固定 0.05% TP)
|
||
# long: 价格相对开仓上涨 edge_exit_pct;short: 下跌 edge_exit_pct
|
||
# current_profit 已是 stake 利润率(含杠杆),换算成“价格优势”用 open_rate 更稳
|
||
entry = trade.open_rate
|
||
if not trade.is_short:
|
||
edge = (current_rate - entry) / entry
|
||
if edge >= self.edge_exit_pct:
|
||
return "spread_edge_restore"
|
||
if edge <= -self.adverse_exit_pct:
|
||
return "adverse_move"
|
||
else:
|
||
edge = (entry - current_rate) / entry
|
||
if edge >= self.edge_exit_pct:
|
||
return "spread_edge_restore"
|
||
if edge <= -self.adverse_exit_pct:
|
||
return "adverse_move"
|
||
|
||
# 重新进入趋势禁做市 → 立刻撤流动性
|
||
try:
|
||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||
if dataframe is not None and len(dataframe):
|
||
if bool(dataframe.iloc[-1].get("trend_block", False)):
|
||
return "trend_block_exit"
|
||
except Exception:
|
||
pass
|
||
|
||
return None
|
||
|
||
def confirm_trade_exit(
|
||
self,
|
||
pair: str,
|
||
trade: Trade,
|
||
order_type: str,
|
||
amount: float,
|
||
rate: float,
|
||
time_in_force: str,
|
||
exit_reason: str,
|
||
current_time: datetime,
|
||
**kwargs,
|
||
) -> bool:
|
||
try:
|
||
profit = trade.calc_profit_ratio(rate)
|
||
if profit < 0:
|
||
self._loss_streak += 1
|
||
if self._loss_streak >= self.consecutive_loss_limit:
|
||
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
|
||
self._loss_streak = 0
|
||
else:
|
||
self._loss_streak = 0
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
@property
|
||
def protections(self):
|
||
return [
|
||
{
|
||
"method": "CooldownPeriod",
|
||
"stop_duration_candles": int(self.cooldown_minutes),
|
||
},
|
||
{
|
||
"method": "StoplossGuard",
|
||
"lookback_period_candles": 60,
|
||
"trade_limit": self.consecutive_loss_limit,
|
||
"stop_duration_candles": self.pause_minutes,
|
||
"only_per_pair": True,
|
||
},
|
||
]
|