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

459 lines
16 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 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 > 单边手续费 × 30.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_ago1m 用 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 / SLROI/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,
}
]