refactor: 缠论引擎包化与 Web 分层(ECR-001)

将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:48:20 +08:00
co-authored by Cursor
parent e2e45bc1bc
commit 74dec4e50b
160 changed files with 25576 additions and 23104 deletions
+2 -292
View File
@@ -1,292 +1,2 @@
"""
中枢结构特征提取 + 标签化
Market Structure Dataset Builder — Phase 1
定位: 训练数据集构建工具,不是交易信号生成器。
Feature 描述中枢内部结构,Label 记录中枢后实际演化。
"""
import math
import json
from typing import Optional
from ChanEnum import Chan_BI_DIR
class ChanPivotClassifier:
"""
中枢结构特征提取 + 标签化
输入: bi_zs_list (list[ChanBIZS])
输出: 结构化数据集 (list[dict])
"""
DATASET_VERSION = "pivot_v1"
FEATURE_SCHEMA = ["duration_norm", "contraction", "shift_norm"]
LABEL_SCHEMA = {"name": "break_direction", "values": ["up", "down", "none"]}
def __init__(self, bi_zs_list: list, symbol: str = "", timeframe: str = ""):
self.bi_zs_list = bi_zs_list
self.symbol = symbol
self.timeframe = timeframe
# ------------------------------------------------------------------
# Feature extraction
# ------------------------------------------------------------------
@staticmethod
def calc_duration(zs) -> int:
"""持续时间: 第一笔首K → 最后一笔末K 的 index 差"""
bi_list = zs.bi_list
start_idx = bi_list[0].start_klc.index
end_idx = bi_list[-1].end_klc.index
return end_idx - start_idx
@staticmethod
def calc_contraction(zs) -> float:
"""收敛率: 后窗口振幅均值 / 前窗口振幅均值"""
bi_list = zs.bi_list
if len(bi_list) < 4:
return 1.0
n = min(3, len(bi_list) // 2)
first_ranges = [bi.high - bi.low for bi in bi_list[:n]]
last_ranges = [bi.high - bi.low for bi in bi_list[-n:]]
first_mean = sum(first_ranges) / len(first_ranges)
last_mean = sum(last_ranges) / len(last_ranges)
if first_mean == 0:
return 1.0
return last_mean / first_mean
@staticmethod
def calc_shift(zs) -> tuple[float, float]:
"""重心漂移: 前后半段重心均值差 (原始值, 归一化值)"""
bi_list = zs.bi_list
mid = len(bi_list) // 2
first_centers = [(bi.high + bi.low) / 2 for bi in bi_list[:mid]]
last_centers = [(bi.high + bi.low) / 2 for bi in bi_list[mid:]]
shift_raw = (
sum(last_centers) / len(last_centers)
- sum(first_centers) / len(first_centers)
)
zs_height = zs.zg - zs.zd
if zs_height == 0:
shift_norm = 0.0
else:
shift_norm = shift_raw / zs_height
return shift_raw, shift_norm
@staticmethod
def compute_duration_norm(duration_raw: int, historical_durations: list) -> float:
"""用历史窗口均值归一化 duration"""
if not historical_durations:
return 1.0
avg = sum(historical_durations) / len(historical_durations)
if avg == 0:
return 1.0
return duration_raw / avg
@staticmethod
def compute_features(zs, historical_durations: Optional[list] = None):
"""计算单个中枢的全部结构特征(实时友好)"""
duration_raw = ChanPivotClassifier.calc_duration(zs)
contraction = ChanPivotClassifier.calc_contraction(zs)
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
if historical_durations is not None and len(historical_durations) > 0:
duration_norm = ChanPivotClassifier.compute_duration_norm(
duration_raw, historical_durations
)
else:
duration_norm = 1.0
return {
"duration_raw": duration_raw,
"duration_norm": round(duration_norm, 4),
"contraction": round(contraction, 4),
"shift_raw": round(shift_raw, 6),
"shift_norm": round(shift_norm, 4),
"zs_height": round(zs.zg - zs.zd, 6),
}
# ------------------------------------------------------------------
# Label computation
# ------------------------------------------------------------------
@staticmethod
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _compute_label(self, zs, contraction: float, shift_norm: float) -> dict:
"""计算标签: up / down / none + 连续置信度"""
bi_out = zs.bi_out
if bi_out is None:
return {
"label": "none",
"label_confidence": 0.0,
"label_detail": {
"bi_out_dir": "none",
"score_breakout": 0.0,
"score_shift": 0.0,
"score_contraction": 0.0,
},
}
zs_height = zs.zg - zs.zd
if zs_height == 0:
zs_height = 1e-8
# ---- 向上突破分数 ----
if bi_out.dir == Chan_BI_DIR.UP:
raw_breakout = (bi_out.high - zs.gg) / zs_height
score_breakout_up = self._clamp(raw_breakout)
score_shift_up = math.tanh(self._clamp(shift_norm, -3.0, 3.0))
score_contraction_up = max(0.0, 1.0 - contraction)
else:
score_breakout_up = 0.0
score_shift_up = 0.0
score_contraction_up = 0.0
up_score = (
score_breakout_up * 0.5
+ score_shift_up * 0.3
+ score_contraction_up * 0.2
)
# ---- 向下突破分数 ----
if bi_out.dir == Chan_BI_DIR.DOWN:
raw_breakout = (zs.dd - bi_out.low) / zs_height
score_breakout_down = self._clamp(raw_breakout)
score_shift_down = math.tanh(self._clamp(-shift_norm, -3.0, 3.0))
score_contraction_down = max(0.0, 1.0 - contraction)
else:
score_breakout_down = 0.0
score_shift_down = 0.0
score_contraction_down = 0.0
down_score = (
score_breakout_down * 0.5
+ score_shift_down * 0.3
+ score_contraction_down * 0.2
)
# ---- 判定 ----
threshold = 0.15
if up_score > down_score and up_score > threshold:
label = "up"
confidence = up_score
detail = {
"bi_out_dir": "up",
"score_breakout": round(score_breakout_up, 4),
"score_shift": round(score_shift_up, 4),
"score_contraction": round(score_contraction_up, 4),
}
elif down_score > up_score and down_score > threshold:
label = "down"
confidence = down_score
detail = {
"bi_out_dir": "down",
"score_breakout": round(score_breakout_down, 4),
"score_shift": round(score_shift_down, 4),
"score_contraction": round(score_contraction_down, 4),
}
else:
label = "none"
confidence = max(up_score, down_score)
bi_dir = "up" if bi_out.dir == Chan_BI_DIR.UP else "down"
detail = {
"bi_out_dir": bi_dir,
"score_breakout": round(max(score_breakout_up, score_breakout_down), 4),
"score_shift": round(max(score_shift_up, score_shift_down), 4),
"score_contraction": round(max(score_contraction_up, score_contraction_down), 4),
}
return {
"label": label,
"label_confidence": round(confidence, 4),
"label_detail": detail,
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def extract(self) -> list[dict]:
"""主入口:对每个中枢提取 3 特征 + 1 标签"""
# 第一遍:计算原始值
raw = []
for i, zs in enumerate(self.bi_zs_list):
if not zs.is_sure or len(zs.bi_list) < 3:
continue
duration_raw = ChanPivotClassifier.calc_duration(zs)
contraction = ChanPivotClassifier.calc_contraction(zs)
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
raw.append({
"zs": zs,
"zs_index": i,
"duration_raw": duration_raw,
"contraction": contraction,
"shift_raw": shift_raw,
"shift_norm": shift_norm,
"zs_height": zs.zg - zs.zd,
})
# 第二遍:组装输出 + 计算 label
result = []
for r in raw:
zs = r["zs"]
historical = [x["duration_raw"] for x in raw]
duration_norm = ChanPivotClassifier.compute_duration_norm(
r["duration_raw"], historical
)
label_info = self._compute_label(zs, r["contraction"], r["shift_norm"])
# 时间处理
start_time = None
end_time = None
if hasattr(zs, "start_time") and zs.start_time is not None:
start_time = str(zs.start_time)
if hasattr(zs, "end_time") and zs.end_time is not None:
end_time = str(zs.end_time)
result.append({
"dataset_version": self.DATASET_VERSION,
"feature_schema": self.FEATURE_SCHEMA,
"label_schema": self.LABEL_SCHEMA,
"symbol": self.symbol,
"timeframe": self.timeframe,
"zs_index": r["zs_index"],
"zs_start_time": start_time,
"zs_end_time": end_time,
"duration_norm": round(duration_norm, 4),
"contraction": round(r["contraction"], 4),
"shift_norm": round(r["shift_norm"], 4),
"label": label_info["label"],
"label_confidence": label_info["label_confidence"],
"label_detail": label_info["label_detail"],
"duration_raw": r["duration_raw"],
"shift_raw": round(r["shift_raw"], 6),
"zs_height": round(r["zs_height"], 6),
})
return result
def export_json(self, path: str):
"""导出为 JSON 文件"""
data = self.extract()
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
return len(data)
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanPivotClassifier import * # noqa: F403