# --- 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