fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新

自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-25 22:57:43 +08:00
co-authored by Cursor
parent 1e60ab3bfa
commit 8ee11317d3
104 changed files with 21452 additions and 4988 deletions
+197
View File
@@ -0,0 +1,197 @@
# --- Do not remove these libs ---
"""
Wyckoff BTC — Market-State Gated SpringDecision Layer
Spring = V1_BASELINEFROZEN
Gate v1.1 = LOCKED default Decision rule:
market_state in {accumulation, markup} -> allow Spring
else -> block
Soft-score 不进默认规则。勿改 Spring;勿全样本扫 Gate。
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from pandas import DataFrame
import pandas as pd
_CHAN = Path(__file__).resolve().parents[1]
if str(_CHAN) not in sys.path:
sys.path.insert(0, str(_CHAN))
from engine.market_state import apply_decision_gate, compute_market_state_8h # noqa: E402
from freqtrade.strategy import merge_informative_pair # noqa: E402
from Wyckoff_BTC_V1_BASELINE import Wyckoff_BTC_V1_BASELINE # noqa: E402
logger = logging.getLogger(__name__)
class Wyckoff_BTC_GATED(Wyckoff_BTC_V1_BASELINE):
"""Baseline Spring + causal Market State Gate。"""
STRATEGY_VERSION = "GATED_V1_1_LOCKED"
SETUP_FAMILY = "SPRING_GATED"
# LOCKED default — 研究脚本可临时改写,跑完必须恢复
gate_mode: str = "state_set"
gate_q_sum: float = 100.0
gate_q_bad: float = 55.0
decision_log_enabled: bool = True
decision_log_path: str = str(_CHAN / "logs" / "wyckoff_decision_events.jsonl")
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe = super().populate_indicators(dataframe, metadata)
pair = metadata["pair"]
btf = self.bias_timeframe or "8h"
raw8 = self.dp.get_pair_dataframe(pair=pair, timeframe=btf)
st8 = compute_market_state_8h(raw8)
# 覆盖默认门闩为当前 class 配置(可能已被脚本锁定)
st8 = apply_decision_gate(
st8,
mode=str(self.gate_mode),
q_sum=float(self.gate_q_sum),
q_bad=float(self.gate_q_bad),
)
keep = [
"date",
"accumulation_score",
"markup_score",
"distribution_score",
"markdown_score",
"range_score",
"market_state",
"allow_spring",
"allow_utad",
"ema_slope",
"dist_ema200",
]
st8 = st8[[c for c in keep if c in st8.columns]].copy()
dataframe = merge_informative_pair(dataframe, st8, self.timeframe, btf, ffill=True)
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe = super().populate_entry_trend(dataframe, metadata)
bs = f"_{self.bias_timeframe or '8h'}"
allow_s = dataframe.get(f"allow_spring{bs}")
allow_u = dataframe.get(f"allow_utad{bs}")
if allow_s is None or allow_u is None:
return dataframe
allow_s = allow_s.fillna(False).astype(bool)
allow_u = allow_u.fillna(False).astype(bool)
block_long = (dataframe["enter_long"] == 1) & (~allow_s)
block_short = (dataframe["enter_short"] == 1) & (~allow_u)
self._log_decision_events(dataframe, metadata, allow_s, allow_u, bs)
dataframe.loc[block_long, ["enter_long", "enter_tag"]] = (0, "")
dataframe.loc[block_short, ["enter_short", "enter_tag"]] = (0, "")
return dataframe
def _decision_log_active(self) -> bool:
if not bool(getattr(self, "decision_log_enabled", True)):
return False
config = getattr(self, "config", {}) or {}
runmode = config.get("runmode")
runmode_value = getattr(runmode, "value", str(runmode) if runmode is not None else "")
if runmode_value:
return runmode_value == "dry_run"
return bool(config.get("dry_run", False))
def _log_decision_events(
self,
dataframe: DataFrame,
metadata: dict,
allow_s: pd.Series,
allow_u: pd.Series,
bias_suffix: str,
) -> None:
if not self._decision_log_active():
return
pair = metadata.get("pair", "")
long_candidates = dataframe["enter_long"] == 1
short_candidates = dataframe["enter_short"] == 1
if not bool(long_candidates.any() or short_candidates.any()):
return
seen = getattr(self, "_decision_log_seen", None)
if seen is None:
seen = set()
self._decision_log_seen = seen
events = []
for idx in dataframe.index[long_candidates]:
events.append(self._decision_event(dataframe.loc[idx], pair, "SPRING_LONG", bool(allow_s.loc[idx]), bias_suffix))
for idx in dataframe.index[short_candidates]:
events.append(self._decision_event(dataframe.loc[idx], pair, "UTAD_SHORT", bool(allow_u.loc[idx]), bias_suffix))
path = Path(str(getattr(self, "decision_log_path", ""))).expanduser()
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
for event in events:
key = (
event["timestamp"],
event["pair"],
event["signal_type"],
event["gate_version"],
)
if key in seen:
continue
seen.add(key)
handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n")
except OSError as exc:
logger.warning("Decision log write failed: %s", exc)
def _decision_event(self, row: pd.Series, pair: str, signal_type: str, allow: bool, bias_suffix: str) -> dict:
state_col = f"market_state{bias_suffix}"
bias_time_col = f"date{bias_suffix}"
state = self._json_value(row.get(state_col))
bias_bar_time = self._json_value(row.get(bias_time_col))
state_missing = state in (None, "", "missing")
block_reason = "" if allow else ("state_missing" if state_missing else "not_in_allow_set")
event = {
"timestamp": self._json_value(row.get("date")),
"pair": pair,
"signal_type": signal_type,
"market_state": state if not state_missing else "missing",
"allow": bool(allow),
"gate_version": self.STRATEGY_VERSION,
"baseline_signal": signal_type,
"block_reason": block_reason,
"bias_bar_time": bias_bar_time,
"would_enter": True,
"order_sent": bool(allow),
}
for score in [
"accumulation_score",
"markup_score",
"distribution_score",
"markdown_score",
"range_score",
]:
event[score] = self._json_value(row.get(f"{score}{bias_suffix}"))
return event
@staticmethod
def _json_value(value):
if value is None:
return None
try:
if pd.isna(value):
return None
except (TypeError, ValueError):
pass
if hasattr(value, "isoformat"):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value