自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""
|
||
BTC Maker Micro Scalper v1.0
|
||
|
||
目标:在 BTCUSDT 永续 1m 级别,用盘口微结构(OBI / Delta / CVD / VWAP)
|
||
做 Maker 挂单,捕捉约 0.03%~0.08% 的微小价差。
|
||
|
||
回测说明:
|
||
- Freqtrade 标准回测只有 OHLCV,没有真实 L2 / Tick。
|
||
- 本策略用 K 线代理重构 OBI / Delta / CVD,使逻辑可回测、可验证。
|
||
- 实盘 / Dry-run 下,confirm_trade_entry 会用真实 10 档 orderbook 覆盖 OBI。
|
||
|
||
不要加入:RSI / MACD / 均线交叉 / 神经网络。
|
||
|
||
运行示例:
|
||
freqtrade download-data -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
|
||
-t 1m --pairs BTC/USDT:USDT --timerange=20260101-
|
||
|
||
freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\
|
||
--strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\
|
||
--timerange=20260101- --fee 0.00016
|
||
|
||
python user_data/Chan/strategies/mms_stats.py
|
||
"""
|
||
|
||
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, DecimalParameter
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _safe_div(num, den):
|
||
return np.where(den != 0, num / den, 0.0)
|
||
|
||
|
||
class BTC_Maker_Micro_Scalper(IStrategy):
|
||
"""
|
||
Maker Micro Scalping MVP — 盘口失衡 + 主动成交方向 + CVD + VWAP 过滤。
|
||
"""
|
||
|
||
INTERFACE_VERSION: int = 3
|
||
timeframe: str = "1m"
|
||
can_short: bool = True
|
||
process_only_new_candles: bool = True
|
||
startup_candle_count: int = 120
|
||
|
||
# 固定小止盈 / 止损(价格百分比,非杠杆后权益)
|
||
# ROI +0.05%;stoploss -0.03%;时间止损 3 分钟在 custom_exit
|
||
minimal_roi = {"0": 0.0005}
|
||
stoploss = -0.0003
|
||
trailing_stop = False
|
||
use_exit_signal = False
|
||
use_custom_stoploss = False
|
||
|
||
# Maker 限价单
|
||
order_types = {
|
||
"entry": "limit",
|
||
"exit": "limit",
|
||
"stoploss": "limit",
|
||
"stoploss_on_exchange": False,
|
||
}
|
||
order_time_in_force = {
|
||
"entry": "GTC",
|
||
"exit": "GTC",
|
||
}
|
||
|
||
# ---- 可调参数(保持与规格一致;后续可 hyperopt)----
|
||
maker_fee = 0.00016 # 0.016%
|
||
atr_fee_mult = 3.0 # ATR > fee * 3
|
||
obi_threshold = 0.15
|
||
tp_pct = 0.0005 # +0.05%
|
||
sl_pct = 0.0003 # -0.03%
|
||
max_hold_minutes = 3
|
||
stake_pct = 0.005 # 单次 0.5% 账户资金
|
||
max_leverage = 3.0
|
||
consecutive_loss_limit = 3
|
||
pause_minutes = 30
|
||
vwap_band = 0.001 # ±0.1%
|
||
ob_levels = 10 # 实盘用 10 档
|
||
tick_size = 0.1 # BTCUSDT 永续常见最小变动
|
||
maker_offset_ticks = 1
|
||
|
||
# Hyperopt 可选(默认关闭,不改变 v1 逻辑)
|
||
buy_obi = DecimalParameter(0.10, 0.30, default=0.15, decimals=2, space="buy", optimize=False)
|
||
|
||
# 运行时状态:连续亏损熔断
|
||
_loss_streak: int = 0
|
||
_pause_until: Optional[datetime] = None
|
||
_maker_fills: int = 0
|
||
_total_fills: int = 0
|
||
|
||
plot_config = {
|
||
"main_plot": {
|
||
"vwap": {"color": "orange"},
|
||
},
|
||
"subplots": {
|
||
"OBI": {"obi": {"color": "blue"}},
|
||
"Delta": {"delta": {"color": "green"}, "delta_ma": {"color": "gray"}},
|
||
"CVD": {"cvd": {"color": "purple"}},
|
||
"ATR_pct": {"atr_pct": {"color": "red"}},
|
||
},
|
||
}
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 微结构指标(OHLCV 代理,供回测;实盘 OBI 可被 orderbook 覆盖)
|
||
# ------------------------------------------------------------------ #
|
||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||
df = dataframe
|
||
|
||
high = df["high"]
|
||
low = df["low"]
|
||
close = df["close"]
|
||
volume = df["volume"].astype(float)
|
||
|
||
# ATR(20) 与相对波动
|
||
df["atr"] = ta.ATR(df, timeperiod=20)
|
||
df["atr_pct"] = _safe_div(df["atr"], close)
|
||
# 规格:ATR > 单边手续费 × 3(0.016% × 3 = 0.048%)
|
||
df["vol_ok"] = df["atr_pct"] > (self.maker_fee * self.atr_fee_mult)
|
||
|
||
# ---- Delta / Buy-Sell 分解(蜡烛代理)----
|
||
# buy_vol ≈ vol * (close-low)/(high-low); sell_vol ≈ vol * (high-close)/(high-low)
|
||
# 先把 close 夹到 [low, high],避免脏数据让 OBI 越界
|
||
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
|
||
|
||
# 最近约 100 笔成交的代理:用最近 N 根 K 线累计 Delta
|
||
# 1m 下无法还原真实 100 trades,用 rolling(5) 近似“近期主动方向”
|
||
df["delta_sum"] = df["delta"].rolling(5, min_periods=1).sum()
|
||
# “Delta 变化率 > 最近 20 秒平均” → 1m 代理:当前 delta > 近 3 根均值
|
||
df["delta_ma"] = df["delta"].rolling(3, min_periods=1).mean()
|
||
df["delta_accel"] = df["delta"] > df["delta_ma"]
|
||
|
||
# CVD
|
||
df["cvd"] = df["delta"].cumsum()
|
||
# 规格:CVD_now > CVD_20s_ago(1m 用 shift(1))
|
||
df["cvd_up"] = df["cvd"] > df["cvd"].shift(1)
|
||
df["cvd_down"] = df["cvd"] < df["cvd"].shift(1)
|
||
|
||
# ---- OBI 代理(无 L2 时)----
|
||
# OBI ≈ (bid_vol - ask_vol)/(bid_vol + ask_vol) ∈ [-1, 1]
|
||
denom = buy_vol + sell_vol
|
||
df["obi"] = pd.Series(_safe_div(buy_vol - sell_vol, denom), index=df.index).clip(-1.0, 1.0)
|
||
|
||
# ---- VWAP(滚动 60 根 ≈ 1h session 近似;避免无限累计漂移)----
|
||
tp = (high + low + close) / 3.0
|
||
window = 60
|
||
cum_pv = (tp * volume).rolling(window, min_periods=1).sum()
|
||
cum_v = volume.rolling(window, min_periods=1).sum()
|
||
df["vwap"] = _safe_div(cum_pv, cum_v)
|
||
|
||
df["below_vwap_band"] = close < df["vwap"] * (1.0 + self.vwap_band)
|
||
df["above_vwap_band"] = close > df["vwap"] * (1.0 - self.vwap_band)
|
||
|
||
# 辅助:标记是否满足波动过滤
|
||
df["fee_atr_floor"] = self.maker_fee * self.atr_fee_mult
|
||
|
||
return df
|
||
|
||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||
obi_th = float(self.buy_obi.value) if hasattr(self.buy_obi, "value") else self.obi_threshold
|
||
|
||
long_cond = (
|
||
dataframe["vol_ok"]
|
||
& (dataframe["obi"] > obi_th)
|
||
& (dataframe["delta_sum"] > 0)
|
||
& dataframe["delta_accel"]
|
||
& dataframe["cvd_up"]
|
||
& dataframe["below_vwap_band"]
|
||
& (dataframe["volume"] > 0)
|
||
)
|
||
short_cond = (
|
||
dataframe["vol_ok"]
|
||
& (dataframe["obi"] < -obi_th)
|
||
& (dataframe["delta_sum"] < 0)
|
||
& (dataframe["delta"] < dataframe["delta_ma"]) # 空头加速(弱于均值)
|
||
& dataframe["cvd_down"]
|
||
& dataframe["above_vwap_band"]
|
||
& (dataframe["volume"] > 0)
|
||
)
|
||
|
||
dataframe.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "mm_long_obi")
|
||
dataframe.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "mm_short_obi")
|
||
return dataframe
|
||
|
||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||
# 出场交给 ROI / stoploss / custom_exit(时间止损)
|
||
dataframe["exit_long"] = 0
|
||
dataframe["exit_short"] = 0
|
||
return dataframe
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Maker 报价:Bid+1tick / Ask-1tick
|
||
# ------------------------------------------------------------------ #
|
||
def custom_entry_price(
|
||
self,
|
||
pair: str,
|
||
trade: Trade | None,
|
||
current_time: datetime,
|
||
proposed_rate: float,
|
||
entry_tag: str | None,
|
||
side: str,
|
||
**kwargs,
|
||
) -> float:
|
||
tick = self.tick_size
|
||
offset = self.maker_offset_ticks * tick
|
||
|
||
# 实盘优先用盘口
|
||
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 float(bids[0][0]) + offset
|
||
if side == "short" and asks:
|
||
return float(asks[0][0]) - offset
|
||
except Exception as e:
|
||
logger.debug("custom_entry_price orderbook fallback: %s", e)
|
||
|
||
# 回测:挂在对侧内侧,模拟 Maker(买低挂 / 卖高挂)
|
||
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:
|
||
tick = self.tick_size
|
||
offset = self.maker_offset_ticks * tick
|
||
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 trade.is_short and bids:
|
||
# 空头平仓 = 买入,挂 bid+1tick
|
||
return float(bids[0][0]) + offset
|
||
if (not trade.is_short) and asks:
|
||
# 多头平仓 = 卖出,挂 ask-1tick
|
||
return float(asks[0][0]) - offset
|
||
except Exception as e:
|
||
logger.debug("custom_exit_price orderbook 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:
|
||
# 单次账户资金 0.5%(作为保证金 stake)
|
||
try:
|
||
wallets = self.wallets
|
||
if wallets:
|
||
free = 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 as e:
|
||
logger.debug("custom_stake_amount fallback: %s", e)
|
||
return proposed_stake * self.stake_pct 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
|
||
|
||
@staticmethod
|
||
def _calc_obi_from_orderbook(ob: dict, levels: int = 10) -> Optional[float]:
|
||
bids = (ob.get("bids") or [])[:levels]
|
||
asks = (ob.get("asks") or [])[:levels]
|
||
if not bids or not asks:
|
||
return None
|
||
bid_vol = sum(float(b[1]) for b in bids)
|
||
ask_vol = sum(float(a[1]) for a in asks)
|
||
tot = bid_vol + ask_vol
|
||
if tot <= 0:
|
||
return None
|
||
return (bid_vol - ask_vol) / tot
|
||
|
||
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):
|
||
logger.info("Paused until %s — skip entry", self._pause_until)
|
||
return False
|
||
|
||
# 实盘:用真实 10 档 OBI 复核
|
||
try:
|
||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||
obi = self._calc_obi_from_orderbook(ob, self.ob_levels)
|
||
if obi is None:
|
||
return False
|
||
if side == "long" and obi <= self.obi_threshold:
|
||
logger.info("Live OBI %.3f <= %.2f, reject long", obi, self.obi_threshold)
|
||
return False
|
||
if side == "short" and obi >= -self.obi_threshold:
|
||
logger.info("Live OBI %.3f >= -%.2f, reject short", obi, self.obi_threshold)
|
||
return False
|
||
except Exception as e:
|
||
logger.warning("confirm_trade_entry orderbook check failed: %s", e)
|
||
|
||
return True
|
||
|
||
def custom_exit(
|
||
self,
|
||
pair: str,
|
||
trade: Trade,
|
||
current_time: datetime,
|
||
current_rate: float,
|
||
current_profit: float,
|
||
**kwargs,
|
||
):
|
||
# 时间止损:持仓 > 3 分钟
|
||
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)
|
||
held = now - open_time
|
||
if held >= timedelta(minutes=self.max_hold_minutes):
|
||
return "time_stop_3m"
|
||
|
||
# 双保险:显式 TP / SL(ROI/stoploss 也会触发)
|
||
if current_profit >= self.tp_pct:
|
||
return "tp_0.05pct"
|
||
if current_profit <= -self.sl_pct:
|
||
return "sl_0.03pct"
|
||
return None
|
||
|
||
def order_filled(
|
||
self,
|
||
pair: str,
|
||
trade: Trade,
|
||
order,
|
||
current_time: datetime,
|
||
**kwargs,
|
||
) -> None:
|
||
self._total_fills += 1
|
||
# limit 单视为 Maker
|
||
otype = getattr(order, "order_type", None) or getattr(order, "ft_order_type", None)
|
||
if otype and str(otype).lower() == "limit":
|
||
self._maker_fills += 1
|
||
|
||
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:
|
||
# 用已实现盈亏更新连续亏损(exit 确认时 trade 可能尚未 close,用 rate 估)
|
||
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)
|
||
logger.warning(
|
||
"Loss streak=%d → pause %d min until %s",
|
||
self._loss_streak,
|
||
self.pause_minutes,
|
||
self._pause_until,
|
||
)
|
||
self._loss_streak = 0
|
||
else:
|
||
self._loss_streak = 0
|
||
except Exception as e:
|
||
logger.debug("confirm_trade_exit streak update: %s", e)
|
||
return True
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Protections(回测需 --enable-protections)
|
||
# ------------------------------------------------------------------ #
|
||
@property
|
||
def protections(self):
|
||
return [
|
||
{
|
||
"method": "StoplossGuard",
|
||
"lookback_period_candles": 30,
|
||
"trade_limit": self.consecutive_loss_limit,
|
||
"stop_duration_candles": self.pause_minutes,
|
||
"only_per_pair": True,
|
||
"only_per_side": False,
|
||
}
|
||
]
|