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:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+458
View File
@@ -0,0 +1,458 @@
"""
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,
}
]
+488
View File
@@ -0,0 +1,488 @@
"""
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,
},
]
+8 -15
View File
@@ -77,7 +77,7 @@ class ChanLun_BTC_15(IStrategy):
trailing_only_offset_is_reached = False
position_adjustment_enable = True
startup_candle_count = 100
startup_candle_count = 1000
time5 = 5
time15 = 15
@@ -88,21 +88,14 @@ class ChanLun_BTC_15(IStrategy):
time5 = 1440
last_time = datetime.now()
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
tf_df_5 = TF_DF(dataframe, self.time5, '5m')
tf_df_15 = TF_DF(dataframe, self.time15, '15m')
tf_df_30 = TF_DF(dataframe, self.time30, '30m')
tf_df_60 = TF_DF(dataframe, self.time60, '60m')
tf_df_4h = TF_DF(dataframe, self.time4h, '4h')
tf_df_1d = TF_DF(dataframe, self.time1d, '1d')
df_5m = resample_to_interval(dataframe, self.time5)
df_15m = resample_to_interval(dataframe, self.time15)
dataframe = TF_DF.add_indicators(dataframe)
df_5m = TF_DF.add_indicators(df_5m)
df_15m = TF_DF.add_indicators(df_15m)
dataframe = resampled_merge(dataframe, tf_df_5.dataframe)
dataframe = resampled_merge(dataframe, tf_df_15.dataframe)
dataframe = resampled_merge(dataframe, tf_df_30.dataframe)
dataframe = resampled_merge(dataframe, tf_df_60.dataframe)
dataframe = resampled_merge(dataframe, tf_df_4h.dataframe)
dataframe = resampled_merge(dataframe, tf_df_1d.dataframe)
dataframe = resampled_merge(dataframe, df_5m)
dataframe = resampled_merge(dataframe, df_15m)
return dataframe
def custom_entry_price(self, pair: str, trade: Trade | None, current_time: datetime, proposed_rate: float,
+434
View File
@@ -0,0 +1,434 @@
"""
MakerEdgeProbe — Freqtrade Dry-run 探针(过渡用)。
正式 Maker / L2 / Edge 采集已迁移到:
nautilus_mm/ NautilusTrader,独立 .venv
本策略仍可用于 Freqtrade 侧对照;新开发请走 nautilus_mm。
运行 Nautilus
cd nautilus_mm && ./scripts/run_probe.sh
分析:
cd nautilus_mm && ./scripts/analyze.sh
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import numpy as np
import talib.abstract as ta
from pandas import DataFrame
from freqtrade.persistence import Trade, Order
from freqtrade.strategy import IStrategy
from maker_edge_logger import MakerEdgeLogger
logger = logging.getLogger(__name__)
class MakerEdgeProbe(IStrategy):
INTERFACE_VERSION = 3
timeframe = "1m"
can_short = True
process_only_new_candles = False
startup_candle_count = 60
minimal_roi = {"0": 0.01}
stoploss = -0.002
trailing_stop = False
use_exit_signal = False
order_types = {
"entry": "limit",
"exit": "limit",
"stoploss": "market",
"stoploss_on_exchange": False,
}
order_time_in_force = {"entry": "GTC", "exit": "GTC"}
tick_size = 0.1
quote_depth_ticks = 1
max_leverage = 1.0
stake_pct = 0.003
max_hold_minutes = 5
edge_exit_pct = 0.0002
adverse_exit_pct = 0.0008
cooldown_minutes = 5
ob_levels = 10
trade_lookback = 100
ema_slope_thr = 0.0002
book_sample_every_sec = 2.0
_logger: MakerEdgeLogger | None = None
_last_mid: float | None = None
_last_book_sample: float = 0.0
_last_entry_time: Optional[datetime] = None
_recent_high: float = 0.0
_recent_low: float = 0.0
_pending_quote_id: Optional[str] = None
_fill_by_trade: dict[int, str] = {}
def bot_start(self, **kwargs) -> None:
self._logger = MakerEdgeLogger(levels=self.ob_levels)
self._fill_by_trade = {}
logger.info("MakerEdgeProbe started. log_dir=%s", self._logger.log_dir)
def _get_logger(self) -> MakerEdgeLogger:
if self._logger is None:
self._logger = MakerEdgeLogger(levels=self.ob_levels)
return self._logger
def _fetch_trades(self, pair: str) -> list:
try:
ex = self.dp._exchange
if ex is None:
return []
api = getattr(ex, "_api", None) or getattr(ex, "api", None)
if api is None:
return []
return api.fetch_trades(pair, limit=self.trade_lookback) or []
except Exception as e:
logger.debug("fetch_trades failed: %s", e)
return []
def _inventory(self) -> float:
try:
inv = 0.0
for t in Trade.get_open_trades():
amt = float(t.amount or 0.0)
inv += -amt if t.is_short else amt
return inv
except Exception:
return 0.0
def _market_state(self, pair: str) -> dict:
state = {
"trend_state": "UNKNOWN",
"atr_pct": None,
"volatility_regime": "UNKNOWN",
"ema_slope": None,
}
try:
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is None or len(df) == 0:
return state
last = df.iloc[-1]
slope = float(last.get("ema_slope") or 0.0)
atr_pct = float(last.get("atr_pct") or 0.0)
state["ema_slope"] = slope
state["atr_pct"] = atr_pct
if bool(last.get("trend_block", False)):
state["trend_state"] = "TREND_UP" if slope > 0 else "TREND_DOWN"
else:
state["trend_state"] = "RANGE"
# 波动分位代理
if "atr_pct" in df.columns:
med = float(df["atr_pct"].tail(60).median() or 0)
if atr_pct > med * 1.8:
state["volatility_regime"] = "HIGH"
elif atr_pct < med * 0.7:
state["volatility_regime"] = "LOW"
else:
state["volatility_regime"] = "NORMAL"
except Exception:
pass
return state
def _snapshot(self, pair: str):
ob = self.dp.orderbook(pair, self.ob_levels)
trades = self._fetch_trades(pair)
snap = MakerEdgeLogger.snapshot_from_orderbook(
ob,
levels=self.ob_levels,
recent_trades=trades,
last_mid=self._last_mid,
liq_proxy_low=self._recent_low or None,
liq_proxy_high=self._recent_high or None,
)
if snap.mid:
self._last_mid = snap.mid
return snap
def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
if self.dp.runmode.value not in ("live", "dry_run"):
return
pair = self.config["exchange"]["pair_whitelist"][0]
try:
snap = self._snapshot(pair)
tick = self.dp.ticker(pair) or {}
last = float(tick.get("last") or tick.get("close") or 0.0) or snap.mid
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is not None and len(df):
self._recent_high = float(df.iloc[-1].get("roll_high") or self._recent_high or last)
self._recent_low = float(df.iloc[-1].get("roll_low") or self._recent_low or last)
lg = self._get_logger()
now = time.time()
# 盘口历史(成交前5s恶化检测依赖此)
if now - self._last_book_sample >= self.book_sample_every_sec:
self._last_book_sample = now
lg.record_book(snap, now=now)
if last:
lg.update_paths(pair, last, now=now)
except Exception as e:
logger.warning("bot_loop_start probe error: %s", e)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe
close, high, low = df["close"], df["high"], df["low"]
volume = df["volume"].astype(float)
close_c = close.clip(lower=low, upper=high)
hl = (high - low).replace(0, np.nan)
buy_frac = ((close_c - low) / hl).fillna(0.5).clip(0, 1)
sell_vol = volume * (1.0 - buy_frac)
buy_vol = volume * buy_frac
df["sell_vol"] = sell_vol
df["buy_vol"] = buy_vol
df["delta"] = buy_vol - sell_vol
vol_ma = volume.rolling(20, min_periods=5).mean()
df["shock_sell"] = (sell_vol > vol_ma * 3) & (df["delta"] < 0)
df["shock_buy"] = (buy_vol > vol_ma * 3) & (df["delta"] > 0)
drop = (close.shift(3) - low).clip(lower=0) / close.shift(3)
up = (high - close.shift(3)).clip(lower=0) / close.shift(3)
df["de_sell"] = (sell_vol.rolling(3).sum() / (drop.replace(0, np.nan) * 1e4)).replace(
[np.inf, -np.inf], np.nan
).fillna(0)
df["de_buy"] = (buy_vol.rolling(3).sum() / (up.replace(0, np.nan) * 1e4)).replace(
[np.inf, -np.inf], np.nan
).fillna(0)
df["ema26"] = ta.EMA(df, timeperiod=26)
df["ema_slope"] = ((df["ema26"] - df["ema26"].shift(5)) / close).fillna(0)
df["trend_block"] = df["ema_slope"].abs() > self.ema_slope_thr
df["atr"] = ta.ATR(df, timeperiod=20)
df["atr_pct"] = (df["atr"] / close).fillna(0)
s_ma, s_ref = sell_vol.rolling(3).mean(), sell_vol.rolling(8).mean()
df["sell_exhaust"] = (s_ma < s_ref * 0.75) & (low >= low.rolling(8).min().shift(1))
b_ma, b_ref = buy_vol.rolling(3).mean(), buy_vol.rolling(8).mean()
df["buy_exhaust"] = (b_ma < b_ref * 0.75) & (high <= high.rolling(8).max().shift(1))
df["roll_high"] = high.rolling(60, min_periods=10).max()
df["roll_low"] = low.rolling(60, min_periods=10).min()
return df
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
df = dataframe
long_c = (
(~df["trend_block"])
& df["shock_sell"].rolling(5).max().astype(bool)
& (df["de_sell"] > 10)
& df["sell_exhaust"]
)
short_c = (
(~df["trend_block"])
& df["shock_buy"].rolling(5).max().astype(bool)
& (df["de_buy"] > 10)
& df["buy_exhaust"]
)
df.loc[long_c, ["enter_long", "enter_tag"]] = (1, "probe_bid_lp")
df.loc[short_c, ["enter_short", "enter_tag"]] = (1, "probe_ask_lp")
return df
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
return dataframe
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.quote_depth_ticks * self.tick_size
try:
snap = self._snapshot(pair)
price = snap.best_bid - offset if side == "long" else snap.best_ask + offset
state = self._market_state(pair)
qid = self._get_logger().create_quote(
pair=pair,
side="bid" if side == "long" else "ask",
quote_price=price,
inventory=self._inventory(),
snap=snap,
reason=entry_tag or "entry",
trade_id=trade.id if trade else None,
state=state,
)
self._pending_quote_id = qid
return price
except Exception as e:
logger.debug("custom_entry_price: %s", e)
return proposed_rate - offset if side == "long" else proposed_rate + offset
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._last_entry_time:
last = self._last_entry_time
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
if now - last < timedelta(minutes=self.cooldown_minutes):
return False
try:
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is not None and len(df) and bool(df.iloc[-1].get("trend_block", False)):
return False
snap = self._snapshot(pair)
if side == "long" and snap.bid_depth_1 < snap.ask_depth_1 * 0.7:
return False
if side == "short" and snap.ask_depth_1 < snap.bid_depth_1 * 0.7:
return False
except Exception:
pass
self._last_entry_time = current_time
return True
def check_entry_timeout(
self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs
) -> bool:
"""超时撤单 → 记录 quote_cancel(坏时间未成交 vs 被动成交的对照)。"""
try:
snap = self._snapshot(pair)
self._get_logger().cancel_quote(
quote_id=self._pending_quote_id,
trade_id=trade.id,
reason="entry_timeout",
snap=snap,
)
except Exception as e:
logger.debug("cancel_quote on timeout: %s", e)
# False = 不额外强制取消;交给 unfilledtimeout 配置。若要立刻取消返回 True
return False
def order_filled(
self,
pair: str,
trade: Trade,
order: Order,
current_time: datetime,
**kwargs,
) -> None:
try:
lg = self._get_logger()
# 入场成交
if order.ft_order_side == trade.entry_side:
snap = self._snapshot(pair)
side = "short" if trade.is_short else "long"
# 粗分 fill_reasontime_to_fill 在 logger 内算;这里标 maker_hit
# 若成交前5s盘口已恶化 → toxic_passive 候选
det = lg.book_deterioration(side)
fill_reason = "toxic_passive" if det.get("pre_5s_deteriorated") else "maker_hit"
if self._pending_quote_id:
lg.bind_trade(self._pending_quote_id, trade.id)
fill_id = lg.log_fill(
pair=pair,
side=side,
fill_price=float(order.safe_price or trade.open_rate),
amount=float(order.safe_filled or order.safe_amount or 0),
inventory=self._inventory(),
snap=snap,
order_type=str(getattr(order, "order_type", None) or "limit"),
quote_id=self._pending_quote_id,
trade_id=trade.id,
fill_reason=fill_reason,
state=self._market_state(pair),
extra={"entry_tag": trade.enter_tag},
)
self._fill_by_trade[trade.id] = fill_id
self._pending_quote_id = None
else:
# 出场:把 exit_reason 挂到入场 fill,供 H2
fill_id = self._fill_by_trade.get(trade.id)
reason = trade.exit_reason or getattr(order, "ft_order_tag", None) or "exit"
if fill_id:
lg.attach_exit_reason(fill_id, str(reason))
except Exception as e:
logger.warning("order_filled log error: %s", e)
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 "probe_time"
entry = trade.open_rate
edge = (
(current_rate - entry) / entry
if not trade.is_short
else (entry - current_rate) / entry
)
if edge >= self.edge_exit_pct:
return "probe_edge_restore"
if edge <= -self.adverse_exit_pct:
return "probe_adverse"
# 趋势切换 → 撤流动性思维
try:
st = self._market_state(pair)
if st.get("trend_state") in ("TREND_UP", "TREND_DOWN"):
# 持仓方向与趋势相反时更危险
if (not trade.is_short and st["trend_state"] == "TREND_DOWN") or (
trade.is_short and st["trend_state"] == "TREND_UP"
):
return "probe_trend_cancel"
except Exception:
pass
return None
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: Optional[float], max_stake: float,
leverage: float, entry_tag: Optional[str], 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)
+449
View File
@@ -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)
+368
View File
@@ -0,0 +1,368 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC V1.0 BASELINE — FROZEN
Status: BASELINE FROZEN (live alias of V1_BASELINE)
Evidence: PASS (+ Limited Evidence, N=20)
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
Risk: small sample — 目标积累 N>=50 再谈规模
Branch A: Spring Reversal
8h bias + 4h structure + 1h Spring/UTAD
Range disabledregime_mode=trend
ATR + 结构止损
setup_type: SPRING / UTAD
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
LPS 是独立 Setup 研究,禁止并入本文件调参。
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, 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 backtesting -c ./user_data/Chan/config/Wyckoff_BTC.json \
# --strategy Wyckoff_BTC --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC(IStrategy):
"""Live alias of V1_BASELINE — 改规则请复制新文件,勿直接改 Baseline。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "V1.0_SPRING"
SETUP_FAMILY = "SPRING"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
# trend = bull|bear onlyRange disabled — 理论一致性约束,非调参)
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.10,
"1440": 0.05,
"4320": 0.025,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.04
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 冻结默认值(optimize=False----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
# Branch A:仅 Spring / UTAD
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
lb = int(self.range_lookback.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
df["rsi"] = ta.RSI(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["tr_high"] = df["high"].rolling(lb).max()
df["tr_low"] = df["low"].rolling(lb).min()
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
down_bar = df["close"] < df["open"]
up_bar = df["close"] > df["open"]
vol_down = np.where(down_bar, df["volume"], np.nan)
vol_up = np.where(up_bar, df["volume"], np.nan)
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
df["effort_absorb"] = (
df["vol_down_ma"].notna()
& df["vol_up_ma"].notna()
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
)
df["accum_ctx"] = (
df["in_range"]
& (df["prior_down"] | (df["close"] < df["ema50"]))
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
)
df["distrib_ctx"] = (
df["in_range"]
& (df["prior_up"] | (df["close"] > df["ema50"]))
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
return df
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
inf = self._add_wyckoff_structure(inf)
keep = [
"date", "atr", "ema50", "ema200", "adx", "rsi",
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
"in_range", "accum_ctx", "distrib_ctx",
"vol_spike", "effort_absorb", "prior_down", "prior_up",
"bull_bias", "bear_bias",
]
inf = inf[[c for c in keep if c in inf.columns]].copy()
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
dataframe = self._merge_tf(dataframe, pair, stf)
btf = self.bias_timeframe
if btf and self.use_bias_filter and btf != stf:
dataframe = self._merge_tf(dataframe, pair, btf)
ss = f"_{stf}"
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
tr_high = dataframe[f"tr_high{ss}"]
tr_low = dataframe[f"tr_low{ss}"]
pierce = float(self.spring_pierce_pct.value)
accum_soft = (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
)
)
distrib_soft = (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
)
)
if btf and self.use_bias_filter:
bs = f"_{btf}" if btf != stf else ss
if f"bear_bias{bs}" in dataframe.columns:
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
dataframe["spring"] = (
tr_low.notna()
& (dataframe["low"] < tr_low * (1.0 - pierce))
& (dataframe["close"] > tr_low)
& (dataframe["close"] > dataframe["open"])
& accum_soft
& vol_mild
& (dataframe["rsi"] < 58)
& dataframe["bias_long_ok"]
)
dataframe["utad"] = (
tr_high.notna()
& (dataframe["high"] > tr_high * (1.0 + pierce))
& (dataframe["close"] < tr_high)
& (dataframe["close"] < dataframe["open"])
& distrib_soft
& vol_mild
& (dataframe["rsi"] > 42)
& dataframe["bias_short_ok"]
)
# 基线不进 SOS/SOW;保留列供 exit 参考
dataframe["sos"] = False
dataframe["sow"] = False
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
if bool(self.use_spring_sig.value):
cond = vol_ok & dataframe["spring"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
if bool(self.use_utad_sig.value):
cond = vol_ok & dataframe["utad"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m # Range disabled
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
ss = f"_{self.structure_timeframe}"
exit_long = dataframe["utad"] | (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] < dataframe["ema21"])
& (dataframe["rsi"] < 45)
)
exit_short = dataframe["spring"] | (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] > dataframe["ema21"])
& (dataframe["rsi"] > 55)
)
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
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 atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in (
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
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)
+197
View File
@@ -0,0 +1,197 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC — Market-State Gated SpringDecision Layer
Spring = V1_BASELINEFROZEN
Gate v1.1 = LOCKED default Decision rule:
market_state in {accumulation, markup} -> allow Spring
else -> block
Soft-score 不进默认规则。勿改 Spring;勿全样本扫 Gate。
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from pandas import DataFrame
import pandas as pd
_CHAN = Path(__file__).resolve().parents[1]
if str(_CHAN) not in sys.path:
sys.path.insert(0, str(_CHAN))
from engine.market_state import apply_decision_gate, compute_market_state_8h # noqa: E402
from freqtrade.strategy import merge_informative_pair # noqa: E402
from Wyckoff_BTC_V1_BASELINE import Wyckoff_BTC_V1_BASELINE # noqa: E402
logger = logging.getLogger(__name__)
class Wyckoff_BTC_GATED(Wyckoff_BTC_V1_BASELINE):
"""Baseline Spring + causal Market State Gate。"""
STRATEGY_VERSION = "GATED_V1_1_LOCKED"
SETUP_FAMILY = "SPRING_GATED"
# LOCKED default — 研究脚本可临时改写,跑完必须恢复
gate_mode: str = "state_set"
gate_q_sum: float = 100.0
gate_q_bad: float = 55.0
decision_log_enabled: bool = True
decision_log_path: str = str(_CHAN / "logs" / "wyckoff_decision_events.jsonl")
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe = super().populate_indicators(dataframe, metadata)
pair = metadata["pair"]
btf = self.bias_timeframe or "8h"
raw8 = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
st8 = compute_market_state_8h(raw8)
# 覆盖默认门闩为当前 class 配置(可能已被脚本锁定)
st8 = apply_decision_gate(
st8,
mode=str(self.gate_mode),
q_sum=float(self.gate_q_sum),
q_bad=float(self.gate_q_bad),
)
keep = [
"date",
"accumulation_score",
"markup_score",
"distribution_score",
"markdown_score",
"range_score",
"market_state",
"allow_spring",
"allow_utad",
"ema_slope",
"dist_ema200",
]
st8 = st8[[c for c in keep if c in st8.columns]].copy()
dataframe = merge_informative_pair(dataframe, st8, self.timeframe, btf, ffill=True)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe = super().populate_entry_trend(dataframe, metadata)
bs = f"_{self.bias_timeframe or '8h'}"
allow_s = dataframe.get(f"allow_spring{bs}")
allow_u = dataframe.get(f"allow_utad{bs}")
if allow_s is None or allow_u is None:
return dataframe
allow_s = allow_s.fillna(False).astype(bool)
allow_u = allow_u.fillna(False).astype(bool)
block_long = (dataframe["enter_long"] == 1) & (~allow_s)
block_short = (dataframe["enter_short"] == 1) & (~allow_u)
self._log_decision_events(dataframe, metadata, allow_s, allow_u, bs)
dataframe.loc[block_long, ["enter_long", "enter_tag"]] = (0, "")
dataframe.loc[block_short, ["enter_short", "enter_tag"]] = (0, "")
return dataframe
def _decision_log_active(self) -> bool:
if not bool(getattr(self, "decision_log_enabled", True)):
return False
config = getattr(self, "config", {}) or {}
runmode = config.get("runmode")
runmode_value = getattr(runmode, "value", str(runmode) if runmode is not None else "")
if runmode_value:
return runmode_value == "dry_run"
return bool(config.get("dry_run", False))
def _log_decision_events(
self,
dataframe: DataFrame,
metadata: dict,
allow_s: pd.Series,
allow_u: pd.Series,
bias_suffix: str,
) -> None:
if not self._decision_log_active():
return
pair = metadata.get("pair", "")
long_candidates = dataframe["enter_long"] == 1
short_candidates = dataframe["enter_short"] == 1
if not bool(long_candidates.any() or short_candidates.any()):
return
seen = getattr(self, "_decision_log_seen", None)
if seen is None:
seen = set()
self._decision_log_seen = seen
events = []
for idx in dataframe.index[long_candidates]:
events.append(self._decision_event(dataframe.loc[idx], pair, "SPRING_LONG", bool(allow_s.loc[idx]), bias_suffix))
for idx in dataframe.index[short_candidates]:
events.append(self._decision_event(dataframe.loc[idx], pair, "UTAD_SHORT", bool(allow_u.loc[idx]), bias_suffix))
path = Path(str(getattr(self, "decision_log_path", ""))).expanduser()
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
for event in events:
key = (
event["timestamp"],
event["pair"],
event["signal_type"],
event["gate_version"],
)
if key in seen:
continue
seen.add(key)
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
except OSError as exc:
logger.warning("Decision log write failed: %s", exc)
def _decision_event(self, row: pd.Series, pair: str, signal_type: str, allow: bool, bias_suffix: str) -> dict:
state_col = f"market_state{bias_suffix}"
bias_time_col = f"date{bias_suffix}"
state = self._json_value(row.get(state_col))
bias_bar_time = self._json_value(row.get(bias_time_col))
state_missing = state in (None, "", "missing")
block_reason = "" if allow else ("state_missing" if state_missing else "not_in_allow_set")
event = {
"timestamp": self._json_value(row.get("date")),
"pair": pair,
"signal_type": signal_type,
"market_state": state if not state_missing else "missing",
"allow": bool(allow),
"gate_version": self.STRATEGY_VERSION,
"baseline_signal": signal_type,
"block_reason": block_reason,
"bias_bar_time": bias_bar_time,
"would_enter": True,
"order_sent": bool(allow),
}
for score in [
"accumulation_score",
"markup_score",
"distribution_score",
"markdown_score",
"range_score",
]:
event[score] = self._json_value(row.get(f"{score}{bias_suffix}"))
return event
@staticmethod
def _json_value(value):
if value is None:
return None
try:
if pd.isna(value):
return None
except (TypeError, ValueError):
pass
if hasattr(value, "isoformat"):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
+42
View File
@@ -0,0 +1,42 @@
# --- Do not remove these libs ---
"""
ARCHIVED — LPS 研究分支已冻结,禁止用于 dry-run / 生产。
见:
user_data/Chan/research/SYSTEM_STATUS.md
user_data/Chan/research/lps_v1_failed/REJECT.md
user_data/Chan/research/lps_v1_1_failed/REJECT.md
user_data/Chan/research/lps_v2_failed/REJECT.md
user_data/Chan/research/lps_v2_failed/Wyckoff_BTC_LPS_V2.py
Baseline: Wyckoff_BTC_V1_BASELINESpring-only
"""
from freqtrade.strategy import IStrategy
from pandas import DataFrame
class Wyckoff_BTC_LPS(IStrategy):
"""Stub: LPS archived. Use Wyckoff_BTC_V1_BASELINE."""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "ARCHIVED"
timeframe = "1h"
can_short = True
startup_candle_count = 20
minimal_roi = {"0": 1}
stoploss = -0.99
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
raise RuntimeError(
"LPS research archived (V1/V1.1/V2 all REJECTED). "
"Use Wyckoff_BTC_V1_BASELINE. See user_data/Chan/research/"
)
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
return dataframe
+368
View File
@@ -0,0 +1,368 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC V1.0 BASELINE — FROZEN
Status: BASELINE FROZEN
Evidence: PASS (+ Limited Evidence, N=20)
Cost Adjusted: PASS (net PF 1.45 @ fee+slip 5bps)
Risk: small sample — 目标积累 N>=50 再谈规模
Branch A: Spring Reversal
8h bias + 4h structure + 1h Spring/UTAD
Range disabledregime_mode=trend
ATR + 结构止损
setup_type: SPRING / UTAD
证据: user_data/Chan/scripts/wyckoff_v1_baseline_phase2.json
LPS 是独立 Setup 研究,禁止并入本文件调参。
"""
from freqtrade.strategy import (
IStrategy, IntParameter, DecimalParameter, CategoricalParameter,
merge_informative_pair, stoploss_from_open, 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 backtesting -c ./user_data/Chan/config/Wyckoff_BTC_V1_BASELINE.json \
# --strategy Wyckoff_BTC_V1_BASELINE --strategy-path ./user_data/Chan/strategies --timerange=20230101-
class Wyckoff_BTC_V1_BASELINE(IStrategy):
"""冻结基线:Spring 反转。禁止继续调参;对比实验请用独立分支。"""
INTERFACE_VERSION = 3
STRATEGY_VERSION = "V1.0_BASELINE"
SETUP_FAMILY = "SPRING"
timeframe = "1h"
structure_timeframe = "4h"
bias_timeframe: Optional[str] = "8h"
use_bias_filter = True
# trend = bull|bear onlyRange disabled — 理论一致性约束,非调参)
regime_mode: str = "trend"
can_short = True
process_only_new_candles = True
startup_candle_count = 220
minimal_roi = {
"0": 0.10,
"1440": 0.05,
"4320": 0.025,
"10080": 0,
}
stoploss = -0.10
use_custom_stoploss = True
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.04
trailing_only_offset_is_reached = True
use_exit_signal = True
exit_profit_only = False
# ---- 冻结默认值(optimize=False----
range_lookback = IntParameter(12, 48, default=24, space="buy", optimize=False)
spring_pierce_pct = DecimalParameter(0.001, 0.012, default=0.004, decimals=3, space="buy", optimize=False)
vol_spike_mult = DecimalParameter(1.1, 2.5, default=1.8, decimals=1, space="buy", optimize=False)
adx_min = IntParameter(10, 28, default=14, space="buy", optimize=False)
tr_pos_long_max = DecimalParameter(0.35, 0.55, default=0.45, decimals=2, space="buy", optimize=False)
tr_pos_short_min = DecimalParameter(0.45, 0.65, default=0.55, decimals=2, space="buy", optimize=False)
atr_sl_mult = DecimalParameter(1.2, 3.5, default=1.5, decimals=1, space="sell", optimize=False)
atr_sl_min = DecimalParameter(0.012, 0.04, default=0.018, decimals=3, space="sell", optimize=False)
atr_sl_max = DecimalParameter(0.05, 0.12, default=0.08, decimals=2, space="sell", optimize=False)
time_stop_hours = IntParameter(48, 240, default=120, space="sell", optimize=False)
# Branch A:仅 Spring / UTAD
use_spring_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_utad_sig = CategoricalParameter([True, False], default=True, space="buy", optimize=False)
use_sos_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
use_sow_sig = CategoricalParameter([True, False], default=False, space="buy", optimize=False)
lev = 1.0
def informative_pairs(self):
pairs = self.dp.current_whitelist() if self.dp else []
tfs = {self.structure_timeframe}
if self.bias_timeframe and self.use_bias_filter:
tfs.add(self.bias_timeframe)
return [(pair, tf) for pair in pairs for tf in tfs]
def _add_wyckoff_structure(self, df: DataFrame) -> DataFrame:
lb = int(self.range_lookback.value)
df["atr"] = ta.ATR(df, timeperiod=14)
df["ema50"] = ta.EMA(df, timeperiod=50)
df["ema200"] = ta.EMA(df, timeperiod=200)
df["adx"] = ta.ADX(df, timeperiod=14)
df["rsi"] = ta.RSI(df, timeperiod=14)
df["volume_ma"] = ta.SMA(df, timeperiod=20, price="volume")
df["tr_high"] = df["high"].rolling(lb).max()
df["tr_low"] = df["low"].rolling(lb).min()
df["tr_mid"] = (df["tr_high"] + df["tr_low"]) / 2.0
df["tr_width"] = (df["tr_high"] - df["tr_low"]) / df["tr_mid"].replace(0, np.nan)
df["tr_width_ma"] = df["tr_width"].rolling(lb).mean()
rng = (df["tr_high"] - df["tr_low"]).replace(0, np.nan)
df["tr_pos"] = (df["close"] - df["tr_low"]) / rng
df["in_range"] = (df["tr_width"] < df["tr_width_ma"] * 1.35) & (df["adx"] < 28)
df["ema50_slope"] = df["ema50"] - df["ema50"].shift(8)
df["prior_down"] = df["ema50_slope"].shift(lb) < 0
df["prior_up"] = df["ema50_slope"].shift(lb) > 0
down_bar = df["close"] < df["open"]
up_bar = df["close"] > df["open"]
vol_down = np.where(down_bar, df["volume"], np.nan)
vol_up = np.where(up_bar, df["volume"], np.nan)
df["vol_down_ma"] = pd.Series(vol_down, index=df.index).rolling(10, min_periods=3).mean()
df["vol_up_ma"] = pd.Series(vol_up, index=df.index).rolling(10, min_periods=3).mean()
df["effort_absorb"] = (
df["vol_down_ma"].notna()
& df["vol_up_ma"].notna()
& (df["vol_up_ma"] > df["vol_down_ma"] * 1.05)
)
df["accum_ctx"] = (
df["in_range"]
& (df["prior_down"] | (df["close"] < df["ema50"]))
& (df["tr_pos"] < float(self.tr_pos_long_max.value))
)
df["distrib_ctx"] = (
df["in_range"]
& (df["prior_up"] | (df["close"] > df["ema50"]))
& (df["tr_pos"] > float(self.tr_pos_short_min.value))
)
df["bull_bias"] = (df["close"] > df["ema200"]) & (df["ema50"] > df["ema200"])
df["bear_bias"] = (df["close"] < df["ema200"]) & (df["ema50"] < df["ema200"])
df["vol_spike"] = df["volume"] > df["volume_ma"] * float(self.vol_spike_mult.value)
return df
def _merge_tf(self, dataframe: DataFrame, pair: str, tf: str) -> DataFrame:
inf = self.dp.get_pair_dataframe(pair=pair, timeframe=tf)
inf = self._add_wyckoff_structure(inf)
keep = [
"date", "atr", "ema50", "ema200", "adx", "rsi",
"tr_high", "tr_low", "tr_mid", "tr_width", "tr_pos",
"in_range", "accum_ctx", "distrib_ctx",
"vol_spike", "effort_absorb", "prior_down", "prior_up",
"bull_bias", "bear_bias",
]
inf = inf[[c for c in keep if c in inf.columns]].copy()
return merge_informative_pair(dataframe, inf, self.timeframe, tf, ffill=True)
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
pair = metadata["pair"]
stf = self.structure_timeframe
dataframe = self._merge_tf(dataframe, pair, stf)
btf = self.bias_timeframe
if btf and self.use_bias_filter and btf != stf:
dataframe = self._merge_tf(dataframe, pair, btf)
ss = f"_{stf}"
dataframe["atr"] = ta.ATR(dataframe, timeperiod=14)
dataframe["ema21"] = ta.EMA(dataframe, timeperiod=21)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["volume_ma"] = ta.SMA(dataframe, timeperiod=20, price="volume")
dataframe["vol_ok"] = dataframe["volume"] > dataframe["volume_ma"] * float(self.vol_spike_mult.value)
tr_high = dataframe[f"tr_high{ss}"]
tr_low = dataframe[f"tr_low{ss}"]
pierce = float(self.spring_pierce_pct.value)
accum_soft = (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_down{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] < float(self.tr_pos_long_max.value))
)
)
distrib_soft = (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
| (
dataframe[f"in_range{ss}"].fillna(False).astype(bool)
& dataframe[f"prior_up{ss}"].fillna(False).astype(bool)
& (dataframe[f"tr_pos{ss}"] > float(self.tr_pos_short_min.value))
)
)
if btf and self.use_bias_filter:
bs = f"_{btf}" if btf != stf else ss
if f"bear_bias{bs}" in dataframe.columns:
dataframe["bias_long_ok"] = ~dataframe[f"bear_bias{bs}"].fillna(False).astype(bool)
dataframe["bias_short_ok"] = ~dataframe[f"bull_bias{bs}"].fillna(False).astype(bool)
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
else:
dataframe["bias_long_ok"] = True
dataframe["bias_short_ok"] = True
vol_mild = dataframe["volume"] > dataframe["volume_ma"] * max(1.1, float(self.vol_spike_mult.value) * 0.85)
dataframe["spring"] = (
tr_low.notna()
& (dataframe["low"] < tr_low * (1.0 - pierce))
& (dataframe["close"] > tr_low)
& (dataframe["close"] > dataframe["open"])
& accum_soft
& vol_mild
& (dataframe["rsi"] < 58)
& dataframe["bias_long_ok"]
)
dataframe["utad"] = (
tr_high.notna()
& (dataframe["high"] > tr_high * (1.0 + pierce))
& (dataframe["close"] < tr_high)
& (dataframe["close"] < dataframe["open"])
& distrib_soft
& vol_mild
& (dataframe["rsi"] > 42)
& dataframe["bias_short_ok"]
)
# 基线不进 SOS/SOW;保留列供 exit 参考
dataframe["sos"] = False
dataframe["sow"] = False
for col in ["spring", "utad", "sos", "sow", "vol_ok", "bias_long_ok", "bias_short_ok"]:
dataframe[col] = dataframe[col].fillna(False).astype(bool)
dataframe["setup_type"] = ""
dataframe.loc[dataframe["spring"], "setup_type"] = "SPRING_LONG"
dataframe.loc[dataframe["utad"], "setup_type"] = "UTAD_SHORT"
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["enter_long"] = 0
dataframe["enter_short"] = 0
dataframe["enter_tag"] = ""
vol_ok = dataframe["volume"] > 0
# 分开标签:禁止把 SPRING / UTAD 混成同一统计桶
if bool(self.use_spring_sig.value):
cond = vol_ok & dataframe["spring"]
dataframe.loc[cond, ["enter_long", "enter_tag"]] = (1, "SPRING_LONG")
if bool(self.use_utad_sig.value):
cond = vol_ok & dataframe["utad"]
dataframe.loc[cond, ["enter_short", "enter_tag"]] = (1, "UTAD_SHORT")
self._apply_regime_filter(dataframe)
return dataframe
def _apply_regime_filter(self, dataframe: DataFrame) -> None:
rm = getattr(self, "regime_mode", "all")
if rm == "all" or not self.bias_timeframe:
return
bs = f"_{self.bias_timeframe}"
bc, ec = f"bull_bias{bs}", f"bear_bias{bs}"
if bc not in dataframe.columns or ec not in dataframe.columns:
return
bull = dataframe[bc].fillna(False).astype(bool)
bear = dataframe[ec].fillna(False).astype(bool)
both = bull & bear
bull, bear = bull & ~both, bear & ~both
range_m = (~bull) & (~bear)
if rm == "bull":
mask = ~bull
elif rm == "bear":
mask = ~bear
elif rm == "range":
mask = ~range_m
elif rm == "trend":
mask = range_m # Range disabled
else:
return
dataframe.loc[mask, ["enter_long", "enter_short"]] = (0, 0)
dataframe.loc[mask, "enter_tag"] = ""
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe["exit_long"] = 0
dataframe["exit_short"] = 0
dataframe["exit_tag"] = ""
ss = f"_{self.structure_timeframe}"
exit_long = dataframe["utad"] | (
dataframe[f"distrib_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] < dataframe["ema21"])
& (dataframe["rsi"] < 45)
)
exit_short = dataframe["spring"] | (
dataframe[f"accum_ctx{ss}"].fillna(False).astype(bool)
& (dataframe["close"] > dataframe["ema21"])
& (dataframe["rsi"] > 55)
)
dataframe.loc[exit_long, ["exit_long", "exit_tag"]] = (1, "wyckoff_phase_flip")
dataframe.loc[exit_short, ["exit_short", "exit_tag"]] = (1, "wyckoff_phase_flip")
return dataframe
def custom_stoploss(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, after_fill: bool, **kwargs,
) -> Optional[float]:
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 atr <= 0 or trade.open_rate <= 0:
return None
atr_dist = float(self.atr_sl_mult.value) * atr
tag = trade.enter_tag or ""
buffer = atr * 0.15
if after_fill and trade.get_custom_data("struct_stop") is None:
if trade.is_short:
trade.set_custom_data("struct_stop", float(last["high"]) + buffer)
else:
trade.set_custom_data("struct_stop", float(last["low"]) - buffer)
struct = trade.get_custom_data("struct_stop")
if trade.is_short:
atr_stop = trade.open_rate + atr_dist
stop_price = min(atr_stop, float(struct)) if struct is not None else atr_stop
else:
atr_stop = trade.open_rate - atr_dist
stop_price = max(atr_stop, float(struct)) if struct is not None else atr_stop
raw = abs(trade.open_rate - stop_price) / trade.open_rate
raw = min(max(raw, float(self.atr_sl_min.value)), float(self.atr_sl_max.value))
if struct is not None and tag in (
"SPRING_LONG", "UTAD_SHORT", "SPRING", "UTAD", "wyckoff_spring", "wyckoff_utad",
):
sl = stoploss_from_absolute(
stop_price, current_rate, is_short=trade.is_short, leverage=trade.leverage
)
return sl if sl and sl > 0 else None
return stoploss_from_open(
-raw, current_profit, is_short=trade.is_short, leverage=trade.leverage
) or None
def custom_exit(
self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs,
) -> Optional[str]:
hours = (current_time - trade.open_date_utc).total_seconds() / 3600
if hours > float(self.time_stop_hours.value) and current_profit < 0:
return "wyckoff_time_stop"
if hours > float(self.time_stop_hours.value) * 2:
return "wyckoff_time_stop_max"
return None
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)
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
Maker Edge Report v0.1
章节:
1. Fill Quality
2. Adverse Selection
3. MAE/MFE (Price + Time)
4. State Attribution
5. Spread Capture / Quote Lifecycle
假设:
H1: P(ret_30s 有利) > 50%
H2: restore exit 优于 all fills
H3: 亏损集中在某类状态 → 应撤单而非止损
用法:
python user_data/Chan/strategies/analyze_maker_edge.py
python user_data/Chan/strategies/analyze_maker_edge.py --report
python user_data/Chan/strategies/analyze_maker_edge.py --min-fills 500
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
def load_events(log_dir: Path) -> pd.DataFrame:
rows = []
files = sorted(log_dir.glob("*.jsonl"))
if not files:
raise FileNotFoundError(f"No jsonl in {log_dir}")
for f in files:
for line in f.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
rows.append(json.loads(line))
return pd.DataFrame(rows)
def _fav_ret(side: pd.Series, fill: pd.Series, px: pd.Series) -> pd.Series:
"""多头:价格涨为正;空头:价格跌为正。"""
raw = (px - fill) / fill
return np.where(side == "long", raw, -raw)
def report(df: pd.DataFrame, min_fills: int = 500, out_path: Path | None = None) -> None:
fills = df[df["event"] == "fill"].copy() if "event" in df.columns else pd.DataFrame()
paths = df[df["event"] == "fill_path"].copy() if "event" in df.columns else pd.DataFrame()
created = df[df["event"] == "quote_created"].copy() if "event" in df.columns else pd.DataFrame()
canceled = df[df["event"] == "quote_canceled"].copy() if "event" in df.columns else pd.DataFrame()
qfilled = df[df["event"] == "quote_filled"].copy() if "event" in df.columns else pd.DataFrame()
exits = df[df["event"] == "fill_exit"].copy() if "event" in df.columns else pd.DataFrame()
lines: list[str] = []
def p(s: str = ""):
lines.append(s)
print(s)
p("=" * 72)
p("Maker Edge Report v0.1")
p("=" * 72)
p(f"quote_created : {len(created)}")
p(f"quote_canceled: {len(canceled)}")
p(f"quote_filled : {len(qfilled)}")
p(f"fills : {len(fills)}")
p(f"fill_paths : {len(paths)} (需成交后≥5m)")
p(f"target fills : ≥{min_fills} [{'OK' if len(fills) >= min_fills else 'COLLECTING'}]")
if fills.empty:
p("\n尚无 fill。先跑 Dry-run 探针。")
return
# merge exit_reason onto paths
if not exits.empty and not paths.empty and "fill_id" in exits.columns:
er = exits.drop_duplicates("fill_id").set_index("fill_id")["exit_reason"]
if "exit_reason" not in paths.columns or paths["exit_reason"].isna().all():
paths = paths.merge(er.rename("exit_reason_x"), left_on="fill_id", right_index=True, how="left")
if "exit_reason" not in paths.columns:
paths["exit_reason"] = paths.get("exit_reason_x")
else:
paths["exit_reason"] = paths["exit_reason"].fillna(paths.get("exit_reason_x"))
# merge fill meta into paths
if not paths.empty:
cols = [
c
for c in [
"side",
"fill_price",
"fill_reason",
"time_to_fill",
"trend_state",
"volatility_regime",
"pre_5s_deteriorated",
"obi",
"trade_imbalance",
"spread",
"entry_tag",
]
if c in fills.columns
]
if cols and "fill_id" in fills.columns:
meta = fills.drop_duplicates("fill_id")[["fill_id"] + cols]
paths = paths.merge(meta, on="fill_id", how="left", suffixes=("", "_f"))
# -------------------- 1. Fill Quality --------------------
p("\n" + "-" * 72)
p("1. Fill Quality")
p("-" * 72)
if "time_to_fill" in fills.columns:
ttf = fills["time_to_fill"].dropna()
if len(ttf):
p(
f"time_to_fill mean={ttf.mean():.1f}s median={ttf.median():.1f}s "
f"p90={ttf.quantile(0.9):.1f}s"
)
fast = fills[fills["time_to_fill"].fillna(1e9) <= 10]
slow = fills[fills["time_to_fill"].fillna(0) > 30]
p(f"fast fills (≤10s): {len(fast)} slow fills (>30s): {len(slow)}")
if "fill_reason" in fills.columns:
p("fill_reason: " + str(fills["fill_reason"].value_counts().to_dict()))
if "pre_5s_deteriorated" in fills.columns:
det = fills["pre_5s_deteriorated"].fillna(False).astype(bool)
p(f"pre_5s book deteriorated: {det.mean()*100:.1f}% of fills")
n_created = max(len(created), 1)
p(f"fill rate (filled/created): {len(qfilled)/n_created*100:.1f}%")
if len(canceled):
p(f"cancel rate: {len(canceled)/n_created*100:.1f}%")
# -------------------- 2. Adverse Selection --------------------
p("\n" + "-" * 72)
p("2. Adverse Selection (fill 后收益分布)")
p("-" * 72)
if paths.empty:
p("等待 fill_path 完成(成交后 ≥5 分钟)…")
else:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fp = paths["fill_price"]
for label, col in [
("10s", "after_10s_price"),
("30s", "after_30s_price"),
("1m", "after_1m_price"),
("5m", "after_5m_price"),
]:
if col not in paths.columns:
continue
fav = pd.Series(_fav_ret(side, fp, paths[col]), index=paths.index)
p(
f" +{label:3s} mean={fav.mean()*100:+.4f}% "
f"median={fav.median()*100:+.4f}% "
f"P(fav)={ (fav>0).mean()*100:.1f}% n={fav.notna().sum()}"
)
# toxic: 10s 立刻不利
if "after_10s_price" in paths.columns:
fav10 = pd.Series(_fav_ret(side, fp, paths["after_10s_price"]), index=paths.index)
p(f" toxic@10s (fav<0): { (fav10<0).mean()*100:.1f}% → 接毒比例")
# -------------------- 3. MAE / MFE --------------------
p("\n" + "-" * 72)
p("3. MAE / MFE (Price + Time)")
p("-" * 72)
if not paths.empty:
if "price_mae" in paths.columns:
p(
f"Price MAE mean={paths['price_mae'].mean():+.2f} "
f"Price MFE mean={paths['price_mfe'].mean():+.2f}"
)
for h in ["10s", "30s", "1m", "5m"]:
mae_c, mfe_c = f"mae_{h}", f"mfe_{h}"
if mae_c in paths.columns and mfe_c in paths.columns:
p(
f" Time@{h:3s} MAE={paths[mae_c].mean()*100:+.4f}% "
f"MFE={paths[mfe_c].mean()*100:+.4f}%"
)
if "mae_5m" in paths.columns and "mfe_5m" in paths.columns:
ratio = paths["mfe_5m"].mean() / abs(paths["mae_5m"].mean()) if paths["mae_5m"].mean() != 0 else np.nan
p(f" MFE/|MAE| @5m = {ratio:.2f}")
# -------------------- 4. State Attribution --------------------
p("\n" + "-" * 72)
p("4. State Attribution (亏损集中在哪?)")
p("-" * 72)
if not paths.empty and "after_5m_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index)
paths = paths.copy()
paths["_fav5"] = fav5
paths["_loss"] = fav5 < 0
loss_rate = float(paths["_loss"].mean())
p(f"overall loss@5m: {loss_rate*100:.1f}%")
for col in ["trend_state", "volatility_regime", "fill_reason", "pre_5s_deteriorated"]:
c = col if col in paths.columns else (col + "_f" if col + "_f" in paths.columns else None)
if not c:
continue
p(f"\n by {c}:")
g = paths.groupby(c).agg(
n=("_fav5", "count"),
loss_rate=("_loss", "mean"),
mean_ret=("_fav5", "mean"),
)
for idx, row in g.iterrows():
p(
f" {idx}: n={int(row['n'])} loss={row['loss_rate']*100:.1f}% "
f"E[ret]={row['mean_ret']*100:+.4f}%"
)
# -------------------- 5. Spread Capture / Lifecycle --------------------
p("\n" + "-" * 72)
p("5. Spread Capture / Quote Lifecycle")
p("-" * 72)
if "spread" in fills.columns and fills["spread"].notna().any():
mid = (fills.get("bid_price", 0) + fills.get("ask_price", 0)) / 2
# 简化:相对价差
p(f"spread at fill mean={fills['spread'].mean():.4f} ({(fills['spread']/fills['fill_price']).mean()*100:.5f}%)")
if not paths.empty and "after_30s_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index)
p(f"mean edge@30s (proxy spread capture): {fav30.mean()*100:+.4f}%")
# -------------------- Hypotheses --------------------
p("\n" + "-" * 72)
p("Hypotheses")
p("-" * 72)
# H1
h1 = None
if not paths.empty and "after_30s_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav30 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_30s_price"]), index=paths.index)
h1 = float((fav30 > 0).mean())
p(f"H1 P(fav@30s)>50%: {h1*100:.1f}% [{'PASS' if h1>0.5 else 'FAIL'}]")
else:
p("H1: insufficient fill_path with after_30s")
# H2 restore vs all
if not paths.empty and "after_5m_price" in paths.columns:
side = paths["side"] if "side" in paths.columns else paths.get("side_f")
fav5 = pd.Series(_fav_ret(side, paths["fill_price"], paths["after_5m_price"]), index=paths.index)
er_col = "exit_reason" if "exit_reason" in paths.columns else None
if er_col and paths[er_col].notna().any():
restore_mask = paths[er_col].astype(str).str.contains("restore", case=False, na=False)
if restore_mask.any():
r_all = float(fav5.mean())
r_res = float(fav5[restore_mask].mean())
verdict = (
"PASS"
if r_res > r_all + 1e-12
else ("INCONCLUSIVE" if abs(r_res - r_all) < 1e-12 else "FAIL")
)
p(
f"H2 restore vs all @5m: restore={r_res*100:+.4f}% all={r_all*100:+.4f}% "
f"[{verdict}] n_restore={int(restore_mask.sum())}"
)
else:
p("H2: no restore exits tagged yet")
else:
p("H2: exit_reason not linked yet (need closed trades)")
else:
p("H2: waiting for paths")
# H3 concentrated losses
if not paths.empty and "_loss" in paths.columns and paths["_loss"].any():
losses = paths[paths["_loss"]]
for col in ["trend_state", "volatility_regime", "fill_reason"]:
c = col if col in losses.columns else None
if c and losses[c].notna().any():
top = losses[c].value_counts(normalize=True).head(1)
if len(top):
k, v = top.index[0], float(top.iloc[0])
p(f"H3 loss concentration: {v*100:.1f}% of losses in {c}={k} "
f"[{'ACTION: cancel in this state' if v>=0.5 else 'diffuse'}]")
else:
p("H3: need completed paths with losses")
p("\n" + "=" * 72)
p("Next: accumulate ≥500 fills (ideal 1000) before designing quote model / v1.2.")
p("=" * 72)
if out_path:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\nReport saved: {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--dir",
type=str,
default=str(Path(__file__).resolve().parents[2] / "logs" / "maker_edge"),
)
ap.add_argument("--min-fills", type=int, default=500)
ap.add_argument("--report", action="store_true", help="also write markdown/txt report")
args = ap.parse_args()
log_dir = Path(args.dir)
if not log_dir.exists():
print(f"日志目录不存在: {log_dir}")
return
try:
df = load_events(log_dir)
except FileNotFoundError as e:
print(e)
return
out = None
if args.report:
out = Path(__file__).resolve().parents[2] / "logs" / "maker_edge" / "Maker_Edge_Report_v0.1.txt"
report(df, min_fills=args.min_fills, out_path=out)
if __name__ == "__main__":
main()
+566
View File
@@ -0,0 +1,566 @@
"""
Maker Edge 事件记录器(Dry-run / Live)— Execution Reality Layer
事件:
- quote_created / quote_canceled / quote_filled (报价生命周期)
- book_tick (可选心跳,用于成交前5s盘口)
- fill (成交瞬间 + 盘口状态)
- fill_path 10s/30s/1m/5m + Price/Time MAE/MFE
输出:user_data/logs/maker_edge/YYYYMMDD.jsonl
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _iso(ts: datetime | float | None = None) -> str:
if ts is None:
t = _utc_now()
elif isinstance(ts, (int, float)):
t = datetime.fromtimestamp(ts, tz=timezone.utc)
else:
t = ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
return t.isoformat()
@dataclass
class MicroSnapshot:
best_bid: float = 0.0
best_ask: float = 0.0
mid: float = 0.0
spread: float = 0.0
bid_depth_1: float = 0.0
ask_depth_1: float = 0.0
bid_depth_5: float = 0.0
ask_depth_5: float = 0.0
bid_depth: float = 0.0 # top-N
ask_depth: float = 0.0
obi: float = 0.0
delta: float = 0.0
trade_imbalance: float = 0.0 # (buy-sell)/(buy+sell) on recent trades
delta_efficiency: float = 0.0
liquidation_distance: float = 0.0
def to_book_fields(self) -> dict[str, float]:
return {
"bid_price": self.best_bid,
"ask_price": self.best_ask,
"mid": self.mid,
"spread": self.spread,
"bid_depth_1": self.bid_depth_1,
"ask_depth_1": self.ask_depth_1,
"bid_depth_5": self.bid_depth_5,
"ask_depth_5": self.ask_depth_5,
"bid_depth": self.bid_depth,
"ask_depth": self.ask_depth,
"obi": self.obi,
"delta": self.delta,
"trade_imbalance": self.trade_imbalance,
"delta_efficiency": self.delta_efficiency,
"liquidation_distance": self.liquidation_distance,
# 兼容旧字段
"buy1_depth": self.bid_depth_1,
"sell1_depth": self.ask_depth_1,
}
@dataclass
class ActiveQuote:
quote_id: str
pair: str
side: str # bid / ask
quote_price: float
created_ts: float
reason: str = ""
trade_id: Optional[int] = None
status: str = "open" # open / filled / canceled
@dataclass
class PendingFillPath:
fill_id: str
pair: str
side: str
fill_price: float
fill_ts: float
quote_id: Optional[str] = None
exit_reason: Optional[str] = None
# horizon prices
after_10s_price: Optional[float] = None
after_30s_price: Optional[float] = None
after_1m_price: Optional[float] = None
after_5m_price: Optional[float] = None
# running extrema
min_price: float = 0.0
max_price: float = 0.0
# time-MAE: worst adverse excursion seen by each horizon (signed, adverse negative for long)
mae_10s: Optional[float] = None
mae_30s: Optional[float] = None
mae_1m: Optional[float] = None
mae_5m: Optional[float] = None
mfe_10s: Optional[float] = None
mfe_30s: Optional[float] = None
mfe_1m: Optional[float] = None
mfe_5m: Optional[float] = None
done: bool = False
def __post_init__(self):
self.min_price = self.fill_price
self.max_price = self.fill_price
def signed_excursions(self) -> tuple[float, float]:
"""Return (mae, mfe) at current min/max. mae<=0 adverse, mfe>=0 favorable."""
if self.side == "long":
mae = (self.min_price - self.fill_price) / self.fill_price
mfe = (self.max_price - self.fill_price) / self.fill_price
else:
mae = (self.fill_price - self.max_price) / self.fill_price
mfe = (self.fill_price - self.min_price) / self.fill_price
return mae, mfe
class MakerEdgeLogger:
def __init__(
self,
log_dir: str | Path | None = None,
levels: int = 10,
book_history_sec: float = 30.0,
):
root = Path(__file__).resolve().parents[2]
self.log_dir = Path(log_dir) if log_dir else root / "logs" / "maker_edge"
self.log_dir.mkdir(parents=True, exist_ok=True)
self.levels = levels
self.book_history_sec = book_history_sec
self._pending: dict[str, PendingFillPath] = {}
self._quotes: dict[str, ActiveQuote] = {} # quote_id -> ActiveQuote
self._quotes_by_trade: dict[int, str] = {} # trade_id -> quote_id
self._book_hist: deque[tuple[float, MicroSnapshot]] = deque(maxlen=2000)
def _file(self) -> Path:
return self.log_dir / f"{_utc_now().strftime('%Y%m%d')}.jsonl"
def write(self, event: dict[str, Any]) -> None:
event.setdefault("ts", _iso())
event.setdefault("ts_epoch", time.time())
with self._file().open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
# ------------------------------------------------------------------ #
# Snapshot
# ------------------------------------------------------------------ #
@staticmethod
def snapshot_from_orderbook(
ob: dict,
levels: int = 10,
recent_trades: list | None = None,
last_mid: float | None = None,
liq_proxy_low: float | None = None,
liq_proxy_high: float | None = None,
) -> MicroSnapshot:
bids = (ob.get("bids") or [])[:levels]
asks = (ob.get("asks") or [])[:levels]
if not bids or not asks:
return MicroSnapshot()
best_bid = float(bids[0][0])
best_ask = float(asks[0][0])
mid = (best_bid + best_ask) / 2.0
spread = best_ask - best_bid
def depth(levels_side, n):
return sum(float(x[1]) for x in levels_side[:n])
bid_depth_1 = depth(bids, 1)
ask_depth_1 = depth(asks, 1)
bid_depth_5 = depth(bids, 5)
ask_depth_5 = depth(asks, 5)
bid_depth = depth(bids, levels)
ask_depth = depth(asks, levels)
tot = bid_depth + ask_depth
obi = ((bid_depth - ask_depth) / tot) if tot > 0 else 0.0
buy_v = sell_v = 0.0
if recent_trades:
for t in recent_trades:
amt = float(t.get("amount") or t.get("qty") or 0.0)
side = (t.get("side") or "").lower()
if side in ("buy", "b"):
buy_v += amt
elif side in ("sell", "s"):
sell_v += amt
delta = buy_v - sell_v
timb_den = buy_v + sell_v
trade_imbalance = ((buy_v - sell_v) / timb_den) if timb_den > 0 else 0.0
de = 0.0
if last_mid and mid and abs(delta) > 1e-12:
de = ((mid - last_mid) / last_mid) / delta
liq_dist = 0.0
if liq_proxy_low and liq_proxy_high and mid:
rng = liq_proxy_high - liq_proxy_low
if rng > 0:
liq_dist = ((mid - liq_proxy_low) / rng) * 2 - 1
return MicroSnapshot(
best_bid=best_bid,
best_ask=best_ask,
mid=mid,
spread=spread,
bid_depth_1=bid_depth_1,
ask_depth_1=ask_depth_1,
bid_depth_5=bid_depth_5,
ask_depth_5=ask_depth_5,
bid_depth=bid_depth,
ask_depth=ask_depth,
obi=obi,
delta=delta,
trade_imbalance=trade_imbalance,
delta_efficiency=de,
liquidation_distance=liq_dist,
)
def record_book(self, snap: MicroSnapshot, now: float | None = None) -> None:
now = now or time.time()
self._book_hist.append((now, snap))
# trim old
cutoff = now - self.book_history_sec
while self._book_hist and self._book_hist[0][0] < cutoff:
self._book_hist.popleft()
def book_at(self, target_ts: float) -> Optional[MicroSnapshot]:
"""取最接近 target_ts 的历史盘口(用于成交前5s)。"""
if not self._book_hist:
return None
best = min(self._book_hist, key=lambda x: abs(x[0] - target_ts))
return best[1]
def book_deterioration(self, side: str, now: float | None = None, lookback: float = 5.0) -> dict:
"""
成交前 lookback 秒盘口是否恶化。
long: bid_depth 下降 / ask_depth 上升 / mid 下跌 → 恶化
"""
now = now or time.time()
cur = self.book_at(now)
past = self.book_at(now - lookback)
if not cur or not past or past.mid <= 0:
return {"book_ok": False}
mid_chg = (cur.mid - past.mid) / past.mid
bid5_chg = (cur.bid_depth_5 - past.bid_depth_5) / past.bid_depth_5 if past.bid_depth_5 else 0.0
ask5_chg = (cur.ask_depth_5 - past.ask_depth_5) / past.ask_depth_5 if past.ask_depth_5 else 0.0
obi_chg = cur.obi - past.obi
if side == "long":
deteriorated = (mid_chg < -0.00005) or (bid5_chg < -0.15) or (obi_chg < -0.1)
else:
deteriorated = (mid_chg > 0.00005) or (ask5_chg < -0.15) or (obi_chg > 0.1)
return {
"book_ok": True,
"pre_5s_mid_chg": mid_chg,
"pre_5s_bid_depth_5_chg": bid5_chg,
"pre_5s_ask_depth_5_chg": ask5_chg,
"pre_5s_obi_chg": obi_chg,
"pre_5s_deteriorated": bool(deteriorated),
"pre_5s_bid_depth_1": past.bid_depth_1,
"pre_5s_ask_depth_1": past.ask_depth_1,
"pre_5s_bid_depth_5": past.bid_depth_5,
"pre_5s_ask_depth_5": past.ask_depth_5,
"pre_5s_obi": past.obi,
"pre_5s_spread": past.spread,
"pre_5s_trade_imbalance": past.trade_imbalance,
}
# ------------------------------------------------------------------ #
# Quote lifecycle
# ------------------------------------------------------------------ #
def create_quote(
self,
pair: str,
side: str,
quote_price: float,
inventory: float,
snap: MicroSnapshot,
reason: str = "",
trade_id: Optional[int] = None,
state: dict | None = None,
) -> str:
qid = uuid.uuid4().hex[:16]
now = time.time()
q = ActiveQuote(
quote_id=qid,
pair=pair,
side=side,
quote_price=quote_price,
created_ts=now,
reason=reason,
trade_id=trade_id,
status="open",
)
self._quotes[qid] = q
if trade_id is not None:
self._quotes_by_trade[trade_id] = qid
ev = {
"event": "quote_created",
"quote_id": qid,
"pair": pair,
"side": side,
"quote_price": quote_price,
"quote_created_time": _iso(now),
"quote_created_epoch": now,
"inventory": inventory,
"reason": reason,
"trade_id": trade_id,
"status": "open",
"filled": False,
}
ev.update(snap.to_book_fields())
if state:
ev.update(state)
self.write(ev)
return qid
def cancel_quote(
self,
quote_id: str | None = None,
trade_id: Optional[int] = None,
reason: str = "timeout",
snap: MicroSnapshot | None = None,
) -> None:
q = None
if quote_id and quote_id in self._quotes:
q = self._quotes[quote_id]
elif trade_id is not None and trade_id in self._quotes_by_trade:
q = self._quotes.get(self._quotes_by_trade[trade_id])
if q is None or q.status != "open":
return
now = time.time()
q.status = "canceled"
ev = {
"event": "quote_canceled",
"quote_id": q.quote_id,
"pair": q.pair,
"side": q.side,
"quote_price": q.quote_price,
"quote_created_time": _iso(q.created_ts),
"quote_cancel_time": _iso(now),
"quote_cancel_epoch": now,
"time_alive_sec": now - q.created_ts,
"cancel_reason": reason,
"filled": False,
"status": "canceled",
"trade_id": q.trade_id,
}
if snap:
ev.update(snap.to_book_fields())
self.write(ev)
def bind_trade(self, quote_id: str, trade_id: int) -> None:
if quote_id in self._quotes:
self._quotes[quote_id].trade_id = trade_id
self._quotes_by_trade[trade_id] = quote_id
# ------------------------------------------------------------------ #
# Fill + path
# ------------------------------------------------------------------ #
def log_fill(
self,
pair: str,
side: str,
fill_price: float,
amount: float,
inventory: float,
snap: MicroSnapshot,
order_type: str = "limit",
quote_id: str | None = None,
trade_id: Optional[int] = None,
fill_reason: str = "maker_hit",
state: dict | None = None,
extra: dict | None = None,
) -> str:
now = time.time()
fill_id = uuid.uuid4().hex[:16]
# resolve quote lifecycle
q: Optional[ActiveQuote] = None
if quote_id and quote_id in self._quotes:
q = self._quotes[quote_id]
elif trade_id is not None and trade_id in self._quotes_by_trade:
q = self._quotes.get(self._quotes_by_trade[trade_id])
time_to_fill = None
quote_created_time = None
quote_price = fill_price
if q is not None:
q.status = "filled"
time_to_fill = now - q.created_ts
quote_created_time = _iso(q.created_ts)
quote_price = q.quote_price
quote_id = q.quote_id
det = self.book_deterioration(side, now=now, lookback=5.0)
ev = {
"event": "fill",
"fill_id": fill_id,
"quote_id": quote_id,
"pair": pair,
"side": side,
"fill_price": fill_price,
"quote_price": quote_price,
"amount": amount,
"inventory": inventory,
"order_type": order_type,
"fill_reason": fill_reason,
"quote_created_time": quote_created_time,
"quote_fill_time": _iso(now),
"time_to_fill": time_to_fill,
"trade_id": trade_id,
"filled": True,
}
ev.update(snap.to_book_fields())
ev.update(det)
if state:
ev.update(state)
if extra:
ev.update(extra)
self.write(ev)
# also emit quote_filled lifecycle event
if q is not None:
self.write(
{
"event": "quote_filled",
"quote_id": q.quote_id,
"fill_id": fill_id,
"pair": pair,
"side": q.side,
"quote_price": q.quote_price,
"quote_created_time": _iso(q.created_ts),
"quote_fill_time": _iso(now),
"time_to_fill": time_to_fill,
"fill_reason": fill_reason,
"filled": True,
"status": "filled",
"trade_id": trade_id,
**snap.to_book_fields(),
**det,
}
)
self._pending[fill_id] = PendingFillPath(
fill_id=fill_id,
pair=pair,
side=side,
fill_price=fill_price,
fill_ts=now,
quote_id=quote_id,
)
return fill_id
def attach_exit_reason(self, fill_id: str, exit_reason: str) -> None:
if fill_id in self._pending:
self._pending[fill_id].exit_reason = exit_reason
# also write lightweight annotation
self.write(
{
"event": "fill_exit",
"fill_id": fill_id,
"exit_reason": exit_reason,
}
)
def update_paths(self, pair: str, last_price: float, now: float | None = None) -> None:
now = now or time.time()
finished = []
for fid, p in self._pending.items():
if p.pair != pair or p.done:
continue
p.min_price = min(p.min_price, last_price)
p.max_price = max(p.max_price, last_price)
mae, mfe = p.signed_excursions()
age = now - p.fill_ts
def mark(horizon_attr_price, horizon_mae, horizon_mfe, sec, price_val):
if getattr(p, horizon_attr_price) is None and age >= sec:
setattr(p, horizon_attr_price, price_val)
setattr(p, horizon_mae, mae)
setattr(p, horizon_mfe, mfe)
mark("after_10s_price", "mae_10s", "mfe_10s", 10, last_price)
mark("after_30s_price", "mae_30s", "mfe_30s", 30, last_price)
mark("after_1m_price", "mae_1m", "mfe_1m", 60, last_price)
if p.after_5m_price is None and age >= 300:
p.after_5m_price = last_price
p.mae_5m = mae
p.mfe_5m = mfe
p.done = True
# Price MAE absolute
if p.side == "long":
price_mae = p.min_price - p.fill_price
price_mfe = p.max_price - p.fill_price
else:
price_mae = p.fill_price - p.max_price # negative if adverse up
price_mfe = p.fill_price - p.min_price
self.write(
{
"event": "fill_path",
"fill_id": p.fill_id,
"quote_id": p.quote_id,
"pair": p.pair,
"side": p.side,
"fill_price": p.fill_price,
"exit_reason": p.exit_reason,
"after_10s_price": p.after_10s_price,
"after_30s_price": p.after_30s_price,
"after_1m_price": p.after_1m_price,
"after_5m_price": p.after_5m_price,
"min_price": p.min_price,
"max_price": p.max_price,
# percent
"mae_10s": p.mae_10s,
"mae_30s": p.mae_30s,
"mae_1m": p.mae_1m,
"mae_5m": p.mae_5m,
"mfe_10s": p.mfe_10s,
"mfe_30s": p.mfe_30s,
"mfe_1m": p.mfe_1m,
"mfe_5m": p.mfe_5m,
# absolute price
"price_mae": price_mae,
"price_mfe": price_mfe,
"price_mae_pct": mae,
"price_mfe_pct": mfe,
}
)
finished.append(fid)
for fid in finished:
self._pending.pop(fid, None)
@property
def pending_count(self) -> int:
return len(self._pending)
# 兼容旧 API
def log_quote(self, *args, **kwargs):
"""Deprecated wrapper → create_quote for live quotes; heartbeat uses book only."""
return self.create_quote(*args, **kwargs)
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
BTC Maker Micro Scalper — 回测结果统计
重点指标:Net Expectancy(不是胜率)
E = 胜率×平均盈利 - 失败率×平均亏损 - 手续费 - 滑点
用法:
python user_data/Chan/strategies/mms_stats.py
python user_data/Chan/strategies/mms_stats.py --file user_data/backtest_results/xxx.zip
python user_data/Chan/strategies/mms_stats.py --slippage 0.00005
"""
from __future__ import annotations
import argparse
import json
import zipfile
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
def _latest_backtest(results_dir: Path) -> Path | None:
zips = sorted(results_dir.glob("backtest-result-*.zip"), key=lambda p: p.stat().st_mtime)
return zips[-1] if zips else None
def _load_trades(path: Path) -> tuple[pd.DataFrame, dict[str, Any]]:
meta: dict[str, Any] = {}
if path.suffix == ".zip":
with zipfile.ZipFile(path, "r") as zf:
names = zf.namelist()
# prefer meta + trades json inside zip
trade_name = next((n for n in names if n.endswith(".json") and "meta" not in n), None)
meta_name = next((n for n in names if n.endswith(".meta.json")), None)
if meta_name:
meta = json.loads(zf.read(meta_name))
if not trade_name:
raise FileNotFoundError(f"No trades json in {path}")
payload = json.loads(zf.read(trade_name))
else:
payload = json.loads(path.read_text())
# Freqtrade formats: {"strategy": {"BTC_...": {"trades": [...]}}}
# or flat list / {"trades": [...]}
trades = None
if isinstance(payload, list):
trades = payload
elif isinstance(payload, dict):
if "trades" in payload:
trades = payload["trades"]
elif isinstance(payload.get("strategy"), dict):
# freqtrade zip: {"strategy": {"BTC_Maker_Micro_Scalper": {"trades": [...]}}}
for name, v in payload["strategy"].items():
if isinstance(v, dict) and "trades" in v:
trades = v["trades"]
meta.setdefault("strategy", name)
break
if trades is None:
for _k, v in payload.items():
if isinstance(v, dict) and "trades" in v:
trades = v["trades"]
meta.setdefault("strategy", _k)
break
if trades is None:
raise ValueError(f"Cannot parse trades from {path}")
df = pd.DataFrame(trades)
return df, meta
def summarize(df: pd.DataFrame, fee_rate: float = 0.00016, slippage: float = 0.0) -> dict[str, Any]:
if df.empty:
return {"error": "no trades"}
# profit_ratio is net of fees in freqtrade; also keep absolute
profit_col = "profit_ratio" if "profit_ratio" in df.columns else "close_profit"
profits = df[profit_col].astype(float)
wins = profits[profits > 0]
losses = profits[profits <= 0]
n = len(profits)
win_rate = len(wins) / n if n else 0.0
loss_rate = 1.0 - win_rate
avg_win = float(wins.mean()) if len(wins) else 0.0
avg_loss = float(losses.mean()) if len(losses) else 0.0 # negative or 0
avg_loss_abs = abs(avg_loss)
gross_profit = float(wins.sum()) if len(wins) else 0.0
gross_loss = float((-losses).sum()) if len(losses) else 0.0
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float("inf")
# 手续费:freqtrade 的 profit 已扣费;这里单独估算双边 maker 占比
# 每笔双边 fee ≈ 2 * fee_rate(相对名义)
fee_per_trade = 2.0 * fee_rate
total_fee_est = n * fee_per_trade
# 滑点假设(每边)
slip_per_trade = 2.0 * slippage
total_slip_est = n * slip_per_trade
# Net Expectancy(每笔期望,比率)
# E = WR*avg_win - LR*avg_loss_abs - fee - slip
expectancy = win_rate * avg_win - loss_rate * avg_loss_abs - fee_per_trade - slip_per_trade
# 注意:若 profit_ratio 已含手续费,上式 fee 会双重扣除。
# 提供两个版本:
# 1) E_raw:用毛期望再减 fee/slip(假设 profit 含 fee → 用 E_from_net
# 2) E_from_net:直接用已实现平均利润(已含 fee)再减额外滑点假设
e_from_net = float(profits.mean()) - slip_per_trade
# 最大回撤(权益曲线,相对)
equity = (1.0 + profits).cumprod()
peak = equity.cummax()
dd = (equity - peak) / peak
max_dd = float(dd.min()) if len(dd) else 0.0
# 持仓时间
hold_min = None
if "open_date" in df.columns and "close_date" in df.columns:
od = pd.to_datetime(df["open_date"], utc=True)
cd = pd.to_datetime(df["close_date"], utc=True)
hold_min = float(((cd - od).dt.total_seconds() / 60.0).mean())
# Maker 成交率:若有 order_type / is_short 等字段无法直接得,默认限价策略按 100% 标注
maker_rate = 1.0
if "exit_reason" in df.columns:
# 无法精确时保持 1.0;实盘可从策略 _maker_fills 导出
pass
# 手续费占毛利
fee_share = None
if "fee_open" in df.columns and "fee_close" in df.columns:
fees = df["fee_open"].astype(float).fillna(0) + df["fee_close"].astype(float).fillna(0)
abs_pnl = df.get("profit_abs", profits).astype(float).abs().sum()
fee_share = float(fees.sum() / abs_pnl) if abs_pnl else None
total_fee_est = float(fees.sum())
return {
"total_trades": n,
"win_rate": win_rate,
"avg_win": avg_win,
"avg_loss": avg_loss,
"profit_factor": profit_factor,
"max_drawdown": max_dd,
"fee_est_total_ratio_units": total_fee_est,
"fee_share_of_abs_pnl": fee_share,
"maker_fill_rate_assumed": maker_rate,
"avg_hold_minutes": hold_min,
"net_expectancy_from_realized": e_from_net,
"net_expectancy_formula_rebuild": expectancy,
"total_profit_ratio_sum": float(profits.sum()),
"avg_profit": float(profits.mean()),
"slippage_assumed_per_side": slippage,
"note": (
"优先看 net_expectancy_from_realized(已含 freqtrade 手续费)。"
"net_expectancy_formula_rebuild 会再减一遍 fee,仅作分解参考。"
),
}
def print_report(stats: dict[str, Any], source: str) -> None:
print("=" * 60)
print("BTC Maker Micro Scalper — Backtest Stats")
print(f"source: {source}")
print("=" * 60)
if "error" in stats:
print(stats["error"])
return
def pct(x):
return f"{x * 100:.4f}%" if x is not None else "n/a"
print(f"总交易次数 : {stats['total_trades']}")
print(f"胜率 : {pct(stats['win_rate'])} (勿作为主指标)")
print(f"平均盈利 : {pct(stats['avg_win'])}")
print(f"平均亏损 : {pct(stats['avg_loss'])}")
print(f"Profit Factor : {stats['profit_factor']:.4f}")
print(f"最大回撤 : {pct(stats['max_drawdown'])}")
print(f"手续费占比(abs pnl) : {stats['fee_share_of_abs_pnl']}")
print(f"Maker成交率(假设) : {pct(stats['maker_fill_rate_assumed'])}")
print(f"平均持仓时间(分钟) : {stats['avg_hold_minutes']}")
print("-" * 60)
print(f"Net Expectancy/笔 : {pct(stats['net_expectancy_from_realized'])} ★主指标")
print(f"公式重建 E(参考) : {pct(stats['net_expectancy_formula_rebuild'])}")
print(f"累计收益(比率和) : {pct(stats['total_profit_ratio_sum'])}")
print(f"平均单笔 : {pct(stats['avg_profit'])}")
print("-" * 60)
print(stats["note"])
print("=" * 60)
def export_equity_csv(df: pd.DataFrame, out: Path) -> None:
if df.empty or "profit_ratio" not in df.columns:
return
profits = df["profit_ratio"].astype(float)
equity = (1.0 + profits).cumprod()
out_df = pd.DataFrame({
"close_date": df.get("close_date"),
"profit_ratio": profits,
"equity": equity,
})
out.parent.mkdir(parents=True, exist_ok=True)
out_df.to_csv(out, index=False)
print(f"净收益曲线已导出: {out}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--file", type=str, default=None, help="backtest zip/json path")
ap.add_argument("--slippage", type=float, default=0.0, help="per-side slippage ratio")
ap.add_argument("--fee", type=float, default=0.00016, help="per-side maker fee ratio")
ap.add_argument(
"--equity-out",
type=str,
default="user_data/plot/mms_equity.csv",
help="equity curve csv",
)
args = ap.parse_args()
root = Path(__file__).resolve().parents[3] # freqtrade root
results_dir = root / "user_data" / "backtest_results"
path = Path(args.file) if args.file else _latest_backtest(results_dir)
if path is None or not path.exists():
print("未找到回测结果。请先运行 backtesting,或用 --file 指定。")
print(
"示例:\n"
" freqtrade backtesting -c ./user_data/Chan/config/BTC_Maker_Micro_Scalper.json \\\n"
" --strategy BTC_Maker_Micro_Scalper --strategy-path ./user_data/Chan/strategies \\\n"
" --timerange=20260101- --fee 0.00016 --enable-protections"
)
return
df, meta = _load_trades(path)
stats = summarize(df, fee_rate=args.fee, slippage=args.slippage)
print_report(stats, str(path))
if meta:
print(f"meta keys: {list(meta.keys())[:8]}")
export_equity_csv(df, root / args.equity_out)
if __name__ == "__main__":
main()