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:
@@ -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 > 单边手续费 × 3(0.016% × 3 = 0.048%)
|
||||
df["vol_ok"] = df["atr_pct"] > (self.maker_fee * self.atr_fee_mult)
|
||||
|
||||
# ---- Delta / Buy-Sell 分解(蜡烛代理)----
|
||||
# buy_vol ≈ vol * (close-low)/(high-low); sell_vol ≈ vol * (high-close)/(high-low)
|
||||
# 先把 close 夹到 [low, high],避免脏数据让 OBI 越界
|
||||
close_c = close.clip(lower=low, upper=high)
|
||||
hl = (high - low).astype(float)
|
||||
hl_safe = hl.where(hl > 0, np.nan)
|
||||
buy_frac = ((close_c - low) / hl_safe).fillna(0.5).clip(0.0, 1.0)
|
||||
sell_frac = 1.0 - buy_frac
|
||||
buy_vol = volume * buy_frac
|
||||
sell_vol = volume * sell_frac
|
||||
|
||||
df["buy_vol"] = buy_vol
|
||||
df["sell_vol"] = sell_vol
|
||||
df["delta"] = buy_vol - sell_vol
|
||||
|
||||
# 最近约 100 笔成交的代理:用最近 N 根 K 线累计 Delta
|
||||
# 1m 下无法还原真实 100 trades,用 rolling(5) 近似“近期主动方向”
|
||||
df["delta_sum"] = df["delta"].rolling(5, min_periods=1).sum()
|
||||
# “Delta 变化率 > 最近 20 秒平均” → 1m 代理:当前 delta > 近 3 根均值
|
||||
df["delta_ma"] = df["delta"].rolling(3, min_periods=1).mean()
|
||||
df["delta_accel"] = df["delta"] > df["delta_ma"]
|
||||
|
||||
# CVD
|
||||
df["cvd"] = df["delta"].cumsum()
|
||||
# 规格:CVD_now > CVD_20s_ago(1m 用 shift(1))
|
||||
df["cvd_up"] = df["cvd"] > df["cvd"].shift(1)
|
||||
df["cvd_down"] = df["cvd"] < df["cvd"].shift(1)
|
||||
|
||||
# ---- OBI 代理(无 L2 时)----
|
||||
# OBI ≈ (bid_vol - ask_vol)/(bid_vol + ask_vol) ∈ [-1, 1]
|
||||
denom = buy_vol + sell_vol
|
||||
df["obi"] = pd.Series(_safe_div(buy_vol - sell_vol, denom), index=df.index).clip(-1.0, 1.0)
|
||||
|
||||
# ---- VWAP(滚动 60 根 ≈ 1h session 近似;避免无限累计漂移)----
|
||||
tp = (high + low + close) / 3.0
|
||||
window = 60
|
||||
cum_pv = (tp * volume).rolling(window, min_periods=1).sum()
|
||||
cum_v = volume.rolling(window, min_periods=1).sum()
|
||||
df["vwap"] = _safe_div(cum_pv, cum_v)
|
||||
|
||||
df["below_vwap_band"] = close < df["vwap"] * (1.0 + self.vwap_band)
|
||||
df["above_vwap_band"] = close > df["vwap"] * (1.0 - self.vwap_band)
|
||||
|
||||
# 辅助:标记是否满足波动过滤
|
||||
df["fee_atr_floor"] = self.maker_fee * self.atr_fee_mult
|
||||
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
obi_th = float(self.buy_obi.value) if hasattr(self.buy_obi, "value") else self.obi_threshold
|
||||
|
||||
long_cond = (
|
||||
dataframe["vol_ok"]
|
||||
& (dataframe["obi"] > obi_th)
|
||||
& (dataframe["delta_sum"] > 0)
|
||||
& dataframe["delta_accel"]
|
||||
& dataframe["cvd_up"]
|
||||
& dataframe["below_vwap_band"]
|
||||
& (dataframe["volume"] > 0)
|
||||
)
|
||||
short_cond = (
|
||||
dataframe["vol_ok"]
|
||||
& (dataframe["obi"] < -obi_th)
|
||||
& (dataframe["delta_sum"] < 0)
|
||||
& (dataframe["delta"] < dataframe["delta_ma"]) # 空头加速(弱于均值)
|
||||
& dataframe["cvd_down"]
|
||||
& dataframe["above_vwap_band"]
|
||||
& (dataframe["volume"] > 0)
|
||||
)
|
||||
|
||||
dataframe.loc[long_cond, ["enter_long", "enter_tag"]] = (1, "mm_long_obi")
|
||||
dataframe.loc[short_cond, ["enter_short", "enter_tag"]] = (1, "mm_short_obi")
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
# 出场交给 ROI / stoploss / custom_exit(时间止损)
|
||||
dataframe["exit_long"] = 0
|
||||
dataframe["exit_short"] = 0
|
||||
return dataframe
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Maker 报价:Bid+1tick / Ask-1tick
|
||||
# ------------------------------------------------------------------ #
|
||||
def custom_entry_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade | None,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
tick = self.tick_size
|
||||
offset = self.maker_offset_ticks * tick
|
||||
|
||||
# 实盘优先用盘口
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if side == "long" and bids:
|
||||
return float(bids[0][0]) + offset
|
||||
if side == "short" and asks:
|
||||
return float(asks[0][0]) - offset
|
||||
except Exception as e:
|
||||
logger.debug("custom_entry_price orderbook fallback: %s", e)
|
||||
|
||||
# 回测:挂在对侧内侧,模拟 Maker(买低挂 / 卖高挂)
|
||||
if side == "long":
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
def custom_exit_price(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
proposed_rate: float,
|
||||
current_profit: float,
|
||||
exit_tag: str | None,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
tick = self.tick_size
|
||||
offset = self.maker_offset_ticks * tick
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
bids = ob.get("bids") or []
|
||||
asks = ob.get("asks") or []
|
||||
if trade.is_short and bids:
|
||||
# 空头平仓 = 买入,挂 bid+1tick
|
||||
return float(bids[0][0]) + offset
|
||||
if (not trade.is_short) and asks:
|
||||
# 多头平仓 = 卖出,挂 ask-1tick
|
||||
return float(asks[0][0]) - offset
|
||||
except Exception as e:
|
||||
logger.debug("custom_exit_price orderbook fallback: %s", e)
|
||||
|
||||
if trade.is_short:
|
||||
return proposed_rate - offset
|
||||
return proposed_rate + offset
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 风控
|
||||
# ------------------------------------------------------------------ #
|
||||
def leverage(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_leverage: float,
|
||||
max_leverage: float,
|
||||
entry_tag: Optional[str],
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
return min(self.max_leverage, float(max_leverage))
|
||||
|
||||
def custom_stake_amount(
|
||||
self,
|
||||
pair: str,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
proposed_stake: float,
|
||||
min_stake: float | None,
|
||||
max_stake: float,
|
||||
leverage: float,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> float:
|
||||
# 单次账户资金 0.5%(作为保证金 stake)
|
||||
try:
|
||||
wallets = self.wallets
|
||||
if wallets:
|
||||
free = wallets.get_free(self.config["stake_currency"])
|
||||
stake = free * self.stake_pct
|
||||
if min_stake:
|
||||
stake = max(stake, min_stake)
|
||||
return min(stake, max_stake)
|
||||
except Exception as e:
|
||||
logger.debug("custom_stake_amount fallback: %s", e)
|
||||
return proposed_stake * self.stake_pct if proposed_stake else proposed_stake
|
||||
|
||||
def _paused(self, current_time: datetime) -> bool:
|
||||
if self._pause_until is None:
|
||||
return False
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
until = self._pause_until if self._pause_until.tzinfo else self._pause_until.replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
return now < until
|
||||
|
||||
@staticmethod
|
||||
def _calc_obi_from_orderbook(ob: dict, levels: int = 10) -> Optional[float]:
|
||||
bids = (ob.get("bids") or [])[:levels]
|
||||
asks = (ob.get("asks") or [])[:levels]
|
||||
if not bids or not asks:
|
||||
return None
|
||||
bid_vol = sum(float(b[1]) for b in bids)
|
||||
ask_vol = sum(float(a[1]) for a in asks)
|
||||
tot = bid_vol + ask_vol
|
||||
if tot <= 0:
|
||||
return None
|
||||
return (bid_vol - ask_vol) / tot
|
||||
|
||||
def confirm_trade_entry(
|
||||
self,
|
||||
pair: str,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
current_time: datetime,
|
||||
entry_tag: str | None,
|
||||
side: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
if self._paused(current_time):
|
||||
logger.info("Paused until %s — skip entry", self._pause_until)
|
||||
return False
|
||||
|
||||
# 实盘:用真实 10 档 OBI 复核
|
||||
try:
|
||||
if self.dp and self.dp.runmode.value in ("live", "dry_run"):
|
||||
ob = self.dp.orderbook(pair, self.ob_levels)
|
||||
obi = self._calc_obi_from_orderbook(ob, self.ob_levels)
|
||||
if obi is None:
|
||||
return False
|
||||
if side == "long" and obi <= self.obi_threshold:
|
||||
logger.info("Live OBI %.3f <= %.2f, reject long", obi, self.obi_threshold)
|
||||
return False
|
||||
if side == "short" and obi >= -self.obi_threshold:
|
||||
logger.info("Live OBI %.3f >= -%.2f, reject short", obi, self.obi_threshold)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("confirm_trade_entry orderbook check failed: %s", e)
|
||||
|
||||
return True
|
||||
|
||||
def custom_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
current_time: datetime,
|
||||
current_rate: float,
|
||||
current_profit: float,
|
||||
**kwargs,
|
||||
):
|
||||
# 时间止损:持仓 > 3 分钟
|
||||
open_time = trade.open_date_utc
|
||||
if open_time.tzinfo is None:
|
||||
open_time = open_time.replace(tzinfo=timezone.utc)
|
||||
now = current_time if current_time.tzinfo else current_time.replace(tzinfo=timezone.utc)
|
||||
held = now - open_time
|
||||
if held >= timedelta(minutes=self.max_hold_minutes):
|
||||
return "time_stop_3m"
|
||||
|
||||
# 双保险:显式 TP / SL(ROI/stoploss 也会触发)
|
||||
if current_profit >= self.tp_pct:
|
||||
return "tp_0.05pct"
|
||||
if current_profit <= -self.sl_pct:
|
||||
return "sl_0.03pct"
|
||||
return None
|
||||
|
||||
def order_filled(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._total_fills += 1
|
||||
# limit 单视为 Maker
|
||||
otype = getattr(order, "order_type", None) or getattr(order, "ft_order_type", None)
|
||||
if otype and str(otype).lower() == "limit":
|
||||
self._maker_fills += 1
|
||||
|
||||
def confirm_trade_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
exit_reason: str,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
# 用已实现盈亏更新连续亏损(exit 确认时 trade 可能尚未 close,用 rate 估)
|
||||
try:
|
||||
profit = trade.calc_profit_ratio(rate)
|
||||
if profit < 0:
|
||||
self._loss_streak += 1
|
||||
if self._loss_streak >= self.consecutive_loss_limit:
|
||||
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
|
||||
logger.warning(
|
||||
"Loss streak=%d → pause %d min until %s",
|
||||
self._loss_streak,
|
||||
self.pause_minutes,
|
||||
self._pause_until,
|
||||
)
|
||||
self._loss_streak = 0
|
||||
else:
|
||||
self._loss_streak = 0
|
||||
except Exception as e:
|
||||
logger.debug("confirm_trade_exit streak update: %s", e)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Protections(回测需 --enable-protections)
|
||||
# ------------------------------------------------------------------ #
|
||||
@property
|
||||
def protections(self):
|
||||
return [
|
||||
{
|
||||
"method": "StoplossGuard",
|
||||
"lookback_period_candles": 30,
|
||||
"trade_limit": self.consecutive_loss_limit,
|
||||
"stop_duration_candles": self.pause_minutes,
|
||||
"only_per_pair": True,
|
||||
"only_per_side": False,
|
||||
}
|
||||
]
|
||||
@@ -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_pct;short: 下跌 edge_exit_pct
|
||||
# current_profit 已是 stake 利润率(含杠杆),换算成“价格优势”用 open_rate 更稳
|
||||
entry = trade.open_rate
|
||||
if not trade.is_short:
|
||||
edge = (current_rate - entry) / entry
|
||||
if edge >= self.edge_exit_pct:
|
||||
return "spread_edge_restore"
|
||||
if edge <= -self.adverse_exit_pct:
|
||||
return "adverse_move"
|
||||
else:
|
||||
edge = (entry - current_rate) / entry
|
||||
if edge >= self.edge_exit_pct:
|
||||
return "spread_edge_restore"
|
||||
if edge <= -self.adverse_exit_pct:
|
||||
return "adverse_move"
|
||||
|
||||
# 重新进入趋势禁做市 → 立刻撤流动性
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe is not None and len(dataframe):
|
||||
if bool(dataframe.iloc[-1].get("trend_block", False)):
|
||||
return "trend_block_exit"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def confirm_trade_exit(
|
||||
self,
|
||||
pair: str,
|
||||
trade: Trade,
|
||||
order_type: str,
|
||||
amount: float,
|
||||
rate: float,
|
||||
time_in_force: str,
|
||||
exit_reason: str,
|
||||
current_time: datetime,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
try:
|
||||
profit = trade.calc_profit_ratio(rate)
|
||||
if profit < 0:
|
||||
self._loss_streak += 1
|
||||
if self._loss_streak >= self.consecutive_loss_limit:
|
||||
self._pause_until = current_time + timedelta(minutes=self.pause_minutes)
|
||||
self._loss_streak = 0
|
||||
else:
|
||||
self._loss_streak = 0
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
@property
|
||||
def protections(self):
|
||||
return [
|
||||
{
|
||||
"method": "CooldownPeriod",
|
||||
"stop_duration_candles": int(self.cooldown_minutes),
|
||||
},
|
||||
{
|
||||
"method": "StoplossGuard",
|
||||
"lookback_period_candles": 60,
|
||||
"trade_limit": self.consecutive_loss_limit,
|
||||
"stop_duration_candles": self.pause_minutes,
|
||||
"only_per_pair": True,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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_reason:time_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)
|
||||
@@ -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)
|
||||
@@ -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 disabled(regime_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 only(Range 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)
|
||||
@@ -0,0 +1,197 @@
|
||||
# --- Do not remove these libs ---
|
||||
"""
|
||||
Wyckoff BTC — Market-State Gated Spring(Decision Layer)
|
||||
|
||||
Spring = V1_BASELINE(FROZEN)
|
||||
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
|
||||
@@ -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_BASELINE(Spring-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
|
||||
@@ -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 disabled(regime_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 only(Range 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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user