自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
198 lines
6.0 KiB
Python
198 lines
6.0 KiB
Python
# --- 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
|