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
+155
View File
@@ -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+非急跌斜率 → accumulationbull+正斜率 → 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+markdownbad = 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)