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,9 @@
|
||||
"""Wyckoff research engines — Decision / Market State(不改 Spring Baseline 信号定义)。"""
|
||||
|
||||
from .market_state import compute_market_state_8h, spring_gate_mask, utad_gate_mask
|
||||
|
||||
__all__ = [
|
||||
"compute_market_state_8h",
|
||||
"spring_gate_mask",
|
||||
"utad_gate_mask",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Market State Engine v1 — 因果可计算(无未来函数)
|
||||
|
||||
仅使用截至当前 8h K 线已收盘信息:
|
||||
EMA50/200、ADX、EMA slope、价格相对 MA200 距离
|
||||
|
||||
输出 0–100 分数 + 主导状态标签(argmax),供 Decision Gate 使用。
|
||||
禁止用事后涨跌路径标注 cycle。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
|
||||
|
||||
def _clip01(x: pd.Series) -> pd.Series:
|
||||
return x.clip(lower=0.0, upper=1.0)
|
||||
|
||||
|
||||
def compute_market_state_8h(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
在原生 8h OHLCV 上计算状态分数。
|
||||
返回列: accumulation_score, markup_score, distribution_score,
|
||||
markdown_score, range_score, market_state, allow_spring, allow_utad
|
||||
"""
|
||||
out = df.copy()
|
||||
out["ema50"] = ta.EMA(out, timeperiod=50)
|
||||
out["ema200"] = ta.EMA(out, timeperiod=200)
|
||||
out["adx"] = ta.ADX(out, timeperiod=14)
|
||||
|
||||
# slope: 过去 6 根 8h(约 2 天),仅用历史
|
||||
out["ema_slope"] = (out["ema50"] - out["ema50"].shift(6)) / out["ema50"].shift(6).replace(0, np.nan)
|
||||
out["dist_ema200"] = (out["close"] - out["ema200"]) / out["ema200"].replace(0, np.nan)
|
||||
|
||||
bull = (out["close"] > out["ema200"]) & (out["ema50"] > out["ema200"])
|
||||
bear = (out["close"] < out["ema200"]) & (out["ema50"] < out["ema200"])
|
||||
range_m = (~bull) & (~bear)
|
||||
|
||||
slope = out["ema_slope"].fillna(0.0)
|
||||
dist = out["dist_ema200"].fillna(0.0)
|
||||
adx = out["adx"].fillna(0.0)
|
||||
|
||||
# ---- 分数:连续、因果、可解释 ----
|
||||
# accumulation: 仍处熊偏结构,但下跌斜率缓和 / 略抬升(吸筹语境)
|
||||
accum = (
|
||||
0.45 * bear.astype(float)
|
||||
+ 0.35 * _clip01((slope + 0.02) / 0.04) # slope 从 -2%→+2% 映射
|
||||
+ 0.20 * _clip01((0.05 + dist) / 0.10) # 仍在 MA200 下方但不极端深
|
||||
) * 100.0
|
||||
|
||||
# markup: 牛偏 + 正斜率 + 价格在 MA200 上方
|
||||
markup = (
|
||||
0.40 * bull.astype(float)
|
||||
+ 0.35 * _clip01(slope / 0.02)
|
||||
+ 0.25 * _clip01(dist / 0.08)
|
||||
) * 100.0
|
||||
|
||||
# distribution: 牛偏但斜率走平/向下(顶部语境)
|
||||
distrib = (
|
||||
0.40 * bull.astype(float)
|
||||
+ 0.40 * _clip01((-slope) / 0.015)
|
||||
+ 0.20 * _clip01((0.12 - dist.abs()) / 0.12)
|
||||
) * 100.0
|
||||
|
||||
# markdown: 熊偏 + 明显负斜率
|
||||
markdown = (
|
||||
0.45 * bear.astype(float)
|
||||
+ 0.40 * _clip01((-slope) / 0.02)
|
||||
+ 0.15 * _clip01((-dist) / 0.10)
|
||||
) * 100.0
|
||||
|
||||
# range: 非明确牛熊,或 ADX 偏低
|
||||
range_s = (
|
||||
0.50 * range_m.astype(float)
|
||||
+ 0.30 * _clip01((22.0 - adx) / 22.0)
|
||||
+ 0.20 * (1.0 - bull.astype(float)) * (1.0 - bear.astype(float))
|
||||
) * 100.0
|
||||
|
||||
out["accumulation_score"] = accum.clip(0, 100)
|
||||
out["markup_score"] = markup.clip(0, 100)
|
||||
out["distribution_score"] = distrib.clip(0, 100)
|
||||
out["markdown_score"] = markdown.clip(0, 100)
|
||||
out["range_score"] = range_s.clip(0, 100)
|
||||
|
||||
# 主导状态:与归因研究同一套因果规则(非事后路径标注)
|
||||
# bear+非急跌斜率 → accumulation;bull+正斜率 → markup;…
|
||||
state = np.full(len(out), "range", dtype=object)
|
||||
state[(bear) & (slope < -0.01)] = "markdown"
|
||||
state[(bear) & (slope >= -0.01)] = "accumulation"
|
||||
state[(bull) & (slope > 0.005)] = "markup"
|
||||
state[(bull) & (slope <= 0.005)] = "distribution"
|
||||
out["market_state"] = state
|
||||
|
||||
# 默认 Gate v1.1:状态集合(soft 阈值由 apply_decision_gate 覆盖)
|
||||
out = apply_decision_gate(out, mode="state_set")
|
||||
return out
|
||||
|
||||
|
||||
def apply_decision_gate(
|
||||
df: pd.DataFrame,
|
||||
*,
|
||||
mode: str = "state_set",
|
||||
q_sum: float = 100.0,
|
||||
q_bad: float = 55.0,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Decision Gate(因果)。
|
||||
|
||||
mode:
|
||||
- state_set: state ∈ {accumulation, markup} / UTAD 镜像
|
||||
- soft_sum: state_set 且 (accum+markup) >= q_sum
|
||||
- soft_bad_cap: state_set 且 max(distrib, range, markdown) <= q_bad
|
||||
"""
|
||||
out = df.copy()
|
||||
state = out["market_state"]
|
||||
spring_state = state.isin(["accumulation", "markup"])
|
||||
utad_state = state.isin(["distribution", "markdown"])
|
||||
|
||||
good_sum = out["accumulation_score"] + out["markup_score"]
|
||||
bad_max = out[["distribution_score", "range_score", "markdown_score"]].max(axis=1)
|
||||
# UTAD 镜像:good = distrib+markdown;bad = accum/range
|
||||
utad_good_sum = out["distribution_score"] + out["markdown_score"]
|
||||
utad_bad_max = out[["accumulation_score", "range_score", "markup_score"]].max(axis=1)
|
||||
|
||||
if mode == "state_set":
|
||||
out["allow_spring"] = spring_state
|
||||
out["allow_utad"] = utad_state
|
||||
elif mode == "soft_sum":
|
||||
out["allow_spring"] = spring_state & (good_sum >= float(q_sum))
|
||||
out["allow_utad"] = utad_state & (utad_good_sum >= float(q_sum))
|
||||
elif mode == "soft_bad_cap":
|
||||
out["allow_spring"] = spring_state & (bad_max <= float(q_bad))
|
||||
out["allow_utad"] = utad_state & (utad_bad_max <= float(q_bad))
|
||||
else:
|
||||
raise ValueError(f"unknown gate mode: {mode}")
|
||||
|
||||
out["gate_mode"] = mode
|
||||
out["gate_q_sum"] = float(q_sum)
|
||||
out["gate_q_bad"] = float(q_bad)
|
||||
return out
|
||||
|
||||
|
||||
def spring_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
|
||||
col = f"allow_spring{suffix}"
|
||||
if col not in dataframe.columns:
|
||||
return pd.Series(True, index=dataframe.index)
|
||||
return dataframe[col].fillna(False).astype(bool)
|
||||
|
||||
|
||||
def utad_gate_mask(dataframe: pd.DataFrame, suffix: str = "_8h") -> pd.Series:
|
||||
col = f"allow_utad{suffix}"
|
||||
if col not in dataframe.columns:
|
||||
return pd.Series(True, index=dataframe.index)
|
||||
return dataframe[col].fillna(False).astype(bool)
|
||||
Reference in New Issue
Block a user