Files
Chan/strategies/BTC_Maker_Micro_Scalper_v11.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

489 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.
"""
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_pctshort: 下跌 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,
},
]