- ChanPivotClassifier: 提取 calc_duration/contraction/shift 为 @staticmethod,新增 compute_features() - ChanPivotMonitor: 实时追踪当前中枢,bi_count 增长时重新计算 shift/contraction/duration - bsp_monitor/fetcher: 改用 data_provider HTTP API 替代直连 CCXT - bsp_monitor/notify: 新增 send_telegram_message() 通用推送 - bsp_monitor/main: 集成 ChanPivotMonitor,有新笔或 BSP 时推送到 Telegram Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
292 lines
10 KiB
Python
292 lines
10 KiB
Python
"""
|
|
中枢结构特征提取 + 标签化
|
|
Market Structure Dataset Builder — Phase 1
|
|
|
|
定位: 训练数据集构建工具,不是交易信号生成器。
|
|
Feature 描述中枢内部结构,Label 记录中枢后实际演化。
|
|
"""
|
|
|
|
import math
|
|
import json
|
|
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: list | None = 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)
|