""" 缠论引擎 v2.1 (原始版, 用于P0-P3改造的基线) """ from __future__ import annotations from dataclasses import dataclass, field from typing import Optional import numpy as np import pandas as pd PIVOT_MAX_EXTEND_SEGS = 6 @dataclass class Fractal: idx: int date: pd.Timestamp kind: str price: float k_high: float k_low: float @dataclass class Bi: start: Fractal end: Fractal direction: str bars: int high: float low: float @property def amplitude(self) -> float: return self.high - self.low @dataclass class Seg: start: Fractal end: Fractal direction: str bis: list high: float low: float confirmed: bool = True @dataclass class Pivot: start_date: pd.Timestamp end_date: pd.Timestamp zg: float zd: float gg: float dd: float bis: list direction: str zg_date: Optional[pd.Timestamp] = None zd_date: Optional[pd.Timestamp] = None gg_date: Optional[pd.Timestamp] = None dd_date: Optional[pd.Timestamp] = None g: Optional[float] = None d: Optional[float] = None state: str = 'new' death_combo: str = '' capped: bool = False upgraded_level: str = '' @dataclass class DivergenceGrade: grade: str area_ok: bool dif_ok: bool area_ratio: float a_area: float c_area: float a_dif: float c_dif: float direction: str reason: str is_trend_divergence: bool = False n_trend_pivots: int = 0 @dataclass class Signal: kind: str date: pd.Timestamp price: float reason: str pivot_zg: Optional[float] = None pivot_zd: Optional[float] = None macd_ratio: Optional[float] = None dif_value: Optional[float] = None n_pivots: int = 0 trend: str = '' extras: dict = field(default_factory=dict) diverge_grade: Optional[DivergenceGrade] = None def merge_klines(df: pd.DataFrame) -> pd.DataFrame: if len(df) == 0: return df.copy() h = df['high'].values; l = df['low'].values; d = df['date'].values out_h, out_l, out_d, out_idx = [h[0]], [l[0]], [d[0]], [0] for i in range(1, len(df)): ph, pl = out_h[-1], out_l[-1]; ch, cl = h[i], l[i] direction = 1 if (len(out_h) >= 2 and out_h[-1] >= out_h[-2]) else (1 if len(out_h) < 2 else -1) contained_a = ph >= ch and pl <= cl contained_b = ch >= ph and cl <= pl if contained_a or contained_b: if direction >= 0: out_h[-1] = max(ph, ch); out_l[-1] = max(pl, cl) else: out_h[-1] = min(ph, ch); out_l[-1] = min(pl, cl) out_idx[-1] = i else: out_h.append(ch); out_l.append(cl); out_d.append(d[i]); out_idx.append(i) return pd.DataFrame({'date': out_d, 'high': out_h, 'low': out_l, 'orig_idx': out_idx}) def find_fractals(merged: pd.DataFrame) -> list: res = [] n = len(merged) if n < 3: return res h = merged['high'].values; l = merged['low'].values; d = merged['date'].values hi = h[1:-1]; hp = h[:-2]; hn = h[2:] li = l[1:-1]; lp = l[:-2]; ln = l[2:] top = (hi > hp) & (hi > hn) & (li >= lp) & (li >= ln) bot = (li < lp) & (li < ln) & (hi <= hp) & (hi <= hn) idxs = np.nonzero(top | bot)[0] if len(idxs) == 0: return res # 仅对被选中的(稀疏)分型点构造 Timestamp, 避免对全序列逐根转换 is_top = top # 局部别名 for j in idxs: i = j + 1 if is_top[j]: res.append(Fractal(i, pd.Timestamp(d[i]), 'top', float(h[i]), float(h[i]), float(l[i]))) else: res.append(Fractal(i, pd.Timestamp(d[i]), 'bottom', float(l[i]), float(h[i]), float(l[i]))) return res def find_bis(fractals: list, min_k: int = 4) -> list: if len(fractals) < 2: return [] cleaned = [fractals[0]] for fx in fractals[1:]: last = cleaned[-1] if fx.kind == last.kind: if fx.kind == 'top' and fx.price > last.price: cleaned[-1] = fx elif fx.kind == 'bottom' and fx.price < last.price: cleaned[-1] = fx else: cleaned.append(fx) alt = [cleaned[0]] for fx in cleaned[1:]: if fx.kind != alt[-1].kind and fx.idx - alt[-1].idx >= min_k - 1: alt.append(fx) elif fx.kind != alt[-1].kind: continue bis = [] for i in range(len(alt) - 1): a, b = alt[i], alt[i+1] if a.kind == b.kind: continue direction = 'up' if b.kind == 'top' else 'down' bis.append(Bi(start=a, end=b, direction=direction, bars=b.idx - a.idx, high=max(a.price, b.price), low=min(a.price, b.price))) return bis def _find_feature_fractal(std: list, seg_dir: str): """[保留] 供调试/对照用的非增量实现; 主路径已改用 _first_feature_fractal_incremental。""" up = (seg_dir == 'up') for i in range(1, len(std) - 1): a = std[i-1]; b = std[i]; c = std[i+1] if up: if b['high'] > a['high'] and b['high'] > c['high'] \ and b['low'] > a['low'] and b['low'] > c['low']: return (i, b.get('has_gap_before', False)) else: if b['low'] < a['low'] and b['low'] < c['low'] \ and b['high'] < a['high'] and b['high'] < c['high']: return (i, b.get('has_gap_before', False)) return None def _first_feature_fractal_incremental(bis, start_i, end_i, seg_dir): """增量构建特征序列, 在第一个特征分型被"锁定"时立即返回。 锁定条件: 出现特征分型(a,b,c)后, 再追加一个新的标准元素(即 c 之后 已有一个不被包含的元素 d)。此时 c 不会再被向后合并改变, 分型 b 的 左右高低关系已固定, 与"先把整段展开再找首个分型"语义等价。 返回 (b_first_bi_idx, b_has_gap_before) 或 None。 """ feat_dir = 'down' if seg_dir == 'up' else 'up' up = (seg_dir == 'up') std = [] # 每元素: [high, low, bi_idx, has_gap_before, first_bi_idx] for k in range(start_i, end_i + 1): b = bis[k] if b.direction != feat_dir: continue ch = b.high; cl = b.low if not std: std.append([ch, cl, k, False, k]); continue prev = std[-1] ph = prev[0]; pl = prev[1] contained = (ph >= ch and pl <= cl) or (ch >= ph and cl <= pl) if contained: if up: prev[0] = ph if ph > ch else ch prev[1] = pl if pl > cl else cl else: prev[0] = ph if ph < ch else ch prev[1] = pl if pl < cl else cl prev[2] = k continue std.append([ch, cl, k, gap_flag(cl, ch, ph, pl)] + [k]) # 锁定检查: 需要至少4个已定型元素, 才能保证倒数第3个(候选分型b) # 的右邻c已被其后元素d终结、不会再被向后合并。 if len(std) >= 4: a = std[-4]; bm = std[-3]; c = std[-2] if up: ok = (bm[0] > a[0] and bm[0] > c[0] and bm[1] > a[1] and bm[1] > c[1]) else: ok = (bm[1] < a[1] and bm[1] < c[1] and bm[0] < a[0] and bm[0] < c[0]) if ok: return (bm[4], bm[3]) # 收尾: 末端无后继元素, 用最终 std 找首个内部分型(与原实现等价) for i in range(1, len(std) - 1): a = std[i-1]; bm = std[i]; c = std[i+1] if up: ok = (bm[0] > a[0] and bm[0] > c[0] and bm[1] > a[1] and bm[1] > c[1]) else: ok = (bm[1] < a[1] and bm[1] < c[1] and bm[0] < a[0] and bm[0] < c[0]) if ok: return (bm[4], bm[3]) return None def gap_flag(cl, ch, ph, pl): return (cl > ph) or (ch < pl) def _seq_fractal_confirms_reversal(bis, start_i, end_i, cur_dir): fr = _first_feature_fractal_incremental(bis, start_i, end_i, cur_dir) if fr is None: return (False, None) feat_first_bi, has_gap = fr seg_end_bi = feat_first_bi - 1 if seg_end_bi <= start_i: return (False, None) if has_gap: opp = 'down' if cur_dir == 'up' else 'up' fr2 = _first_feature_fractal_incremental(bis, feat_first_bi, end_i, opp) if fr2 is None: return (False, None) return (True, seg_end_bi) def find_segs(bis: list) -> list: n = len(bis) if n < 3: return [] base = bis[0].start.price look = min(3, n) net = bis[look - 1].end.price - base cur_dir = 'up' if net > 0 else 'down' segs = [] i = 0 while i < n - 2: confirmed, seg_end_bi = _seq_fractal_confirms_reversal(bis, i, n - 1, cur_dir) if confirmed and seg_end_bi > i: seg_bis = bis[i:seg_end_bi + 1] net_up = seg_bis[-1].end.price > seg_bis[0].start.price if (cur_dir == 'up') == net_up: segs.append(Seg(start=bis[i].start, end=seg_bis[-1].end, direction=cur_dir, bis=seg_bis, high=max(b.high for b in seg_bis), low=min(b.low for b in seg_bis), confirmed=True)) i = seg_end_bi + 1 cur_dir = 'down' if cur_dir == 'up' else 'up' continue alt = 'down' if cur_dir == 'up' else 'up' confirmed2, seg_end_bi2 = _seq_fractal_confirms_reversal(bis, i, n - 1, alt) if confirmed2 and seg_end_bi2 > i: seg_bis = bis[i:seg_end_bi2 + 1] net_up = seg_bis[-1].end.price > seg_bis[0].start.price if (alt == 'up') == net_up: segs.append(Seg(start=seg_bis[0].start, end=seg_bis[-1].end, direction=alt, bis=seg_bis, high=max(b.high for b in seg_bis), low=min(b.low for b in seg_bis), confirmed=True)) i = seg_end_bi2 + 1 cur_dir = 'down' if alt == 'up' else 'up' continue break if i < n - 1 and (n - i) >= 1: seg_bis = bis[i:] if len(seg_bis) >= 1: net_up = seg_bis[-1].end.price > seg_bis[0].start.price d = 'up' if net_up else 'down' segs.append(Seg(start=seg_bis[0].start, end=seg_bis[-1].end, direction=d, bis=seg_bis, high=max(b.high for b in seg_bis), low=min(b.low for b in seg_bis), confirmed=False)) return segs PIVOT_UPGRADE_SPAN_DAYS = 540 def find_pivots(bis: list, segs: Optional[list] = None) -> list: confirmed_segs = [s for s in (segs or []) if getattr(s, 'confirmed', True)] units = confirmed_segs if len(confirmed_segs) >= 3 else bis using_segs = units is confirmed_segs pivots = []; n = len(units) if n < 3: return pivots bi_pos = {id(b): k for k, b in enumerate(bis)} def _unit_bi_range(u): if hasattr(u, 'bis'): idxs = [bi_pos[id(b)] for b in u.bis if id(b) in bi_pos] return (min(idxs), max(idxs)) if idxs else (0, 0) k = bi_pos.get(id(u), 0) return (k, k) def _unit_bi_indices(unit_list): out = [] for u in unit_list: a, b = _unit_bi_range(u) out.extend(range(a, b + 1)) return sorted(set(out)) def _unit_high_date(u): return u.start.date if u.start.price >= u.end.price else u.end.date def _unit_low_date(u): return u.start.date if u.start.price <= u.end.price else u.end.date max_ext = PIVOT_MAX_EXTEND_SEGS if PIVOT_MAX_EXTEND_SEGS else 10 ** 9 i = 0 while i <= n - 3: b1, b2, b3 = units[i], units[i+1], units[i+2] r1 = (b1.low, b1.high) r2 = (b2.low, b2.high) r3 = (b3.low, b3.high) zg = min(r1[1], r2[1], r3[1]); zd = max(r1[0], r2[0], r3[0]) if zg > zd: direction = b1.direction; zg_orig, zd_orig = zg, zd; gg, dd = zg, zd highs = [r1[1], r2[1], r3[1]]; lows = [r1[0], r2[0], r3[0]] zg_bi = (b1, b2, b3)[highs.index(zg_orig)] zd_bi = (b1, b2, b3)[lows.index(zd_orig)] zg_d = _unit_high_date(zg_bi) zd_d = _unit_low_date(zd_bi) gg_d, dd_d = zg_d, zd_d zn_dir = direction gn_list = []; dn_list = [] for bb in (b1, b2, b3): if bb.direction == zn_dir: gn_list.append(max(bb.start.price, bb.end.price)) dn_list.append(min(bb.start.price, bb.end.price)) piv_units = [i, i+1, i+2]; j = i + 3 capped = False while j < n: if (len(piv_units) - 3) >= max_ext: capped = True break bj = units[j]; lo_j = bj.low; hi_j = bj.high if hi_j >= zd_orig and lo_j <= zg_orig: if hi_j > gg: gg = hi_j; gg_d = _unit_high_date(bj) if lo_j < dd: dd = lo_j; dd_d = _unit_low_date(bj) if bj.direction == zn_dir: gn_list.append(hi_j); dn_list.append(lo_j) piv_units.append(j); j += 1 else: break g_val = min(gn_list) if gn_list else zg_orig d_val = max(dn_list) if dn_list else zd_orig piv_bis = _unit_bi_indices([units[k] for k in piv_units]) if using_segs else piv_units p_start = b1.start.date p_end = units[piv_units[-1]].end.date piv = Pivot(start_date=p_start, end_date=p_end, zg=zg_orig, zd=zd_orig, gg=gg, dd=dd, bis=piv_bis, direction=direction, zg_date=zg_d, zd_date=zd_d, gg_date=gg_d, dd_date=dd_d, g=g_val, d=d_val, capped=capped) try: span_days = (pd.Timestamp(p_end) - pd.Timestamp(p_start)).days except Exception: span_days = 0 if capped or span_days > PIVOT_UPGRADE_SPAN_DAYS: piv.upgraded_level = 'weekly' pivots.append(piv) i = piv_units[-1] + 1 else: i += 1 for k in range(1, len(pivots)): prev, cur = pivots[k-1], pivots[k] no_overlap = (cur.dd > prev.gg) or (cur.gg < prev.dd) if no_overlap: cur.state = 'new' else: cur.state = 'expand' for k in range(len(pivots) - 1): cur = pivots[k] nxt = pivots[k + 1] gap_start = cur.bis[-1] + 1 gap_end = nxt.bis[0] gap_bis = bis[gap_start:gap_end] if gap_end > gap_start else [] if len(gap_bis) >= 2: leave = gap_bis[:max(1, len(gap_bis) // 2)] pull = gap_bis[max(1, len(gap_bis) // 2):] leave_trend = len(leave) >= 3 pull_trend = len(pull) >= 3 if leave_trend and not pull_trend: cur.death_combo = 'trend+consol' elif leave_trend and pull_trend: cur.death_combo = 'trend+counter' else: cur.death_combo = 'consol+counter' return pivots def classify_trend(pivots: list) -> str: if len(pivots) < 2: return 'consolidation' p1, p2 = pivots[-2], pivots[-1] if p2.dd > p1.gg: return 'up_trend' if p2.gg < p1.dd: return 'down_trend' if (p2.zg < p1.zd and p2.gg >= p1.dd) or (p2.zd > p1.zg and p2.dd <= p1.gg): return 'expanding' return 'consolidation' def count_trend_pivots(pivots: list) -> int: if not pivots: return 0 if len(pivots) == 1: return 1 cnt = 1 for k in range(len(pivots) - 1, 0, -1): p_prev, p_cur = pivots[k - 1], pivots[k] if p_cur.dd > p_prev.gg: cnt += 1 elif p_cur.gg < p_prev.dd: cnt += 1 else: break return cnt def calc_macd(close: pd.Series, fast=12, slow=26, signal=9): ema_fast = close.ewm(span=fast, adjust=False).mean() ema_slow = close.ewm(span=slow, adjust=False).mean() dif = ema_fast - ema_slow dea = dif.ewm(span=signal, adjust=False).mean() macd_bar = 2 * (dif - dea) return dif, dea, macd_bar def macd_area_between(start_date, end_date, bar_series, date_series, direction): mask = (date_series >= start_date) & (date_series <= end_date) vals = bar_series[mask] if len(vals) == 0: return 0.0 if direction == 'up': return float(vals.clip(lower=0).sum()) return float(vals.clip(upper=0).abs().sum()) def dif_extreme_in(start_date, end_date, dif_series, date_series, kind='peak'): mask = (date_series >= start_date) & (date_series <= end_date) vals = dif_series[mask] if len(vals) == 0: return 0.0 return float(vals.max()) if kind == 'peak' else float(vals.min()) class ChanAnalyzer: DIVERGE_RATIO = 0.80 PIVOT_TOLERANCE = 0.02 MIN_BI_BARS = 4 DIF_TOLERANCE = 0.01 CFG = { 'b1_allow_consol_diverge': True, 'b3s3_first_pullback_only': False, 'b2s2_anchor_to_first': False, 'b2_macd_zero_pullback': False, 'drop_upgraded_pivots': False, # L88-90 中阴阶段MACD精确运用: 中阴判定除BOLL收口外, 加入MACD特征 # 'off' = 维持原判定(仅BOLL收口+末笔未离开中枢) # 'and' = 须同时满足"黄白线绕0轴缠绕"(更严格, 减少误判中阴而拦截的好买点) # 'or' = 满足其一即算中阴(更宽松, 拦截更多) 'zhongyin_macd_mode': 'or', # 实测'or'最优: 累计收益+35pp(383%→418%), 胜率45.3%→46.8% } def __init__(self, df: pd.DataFrame): self.df_raw = df.reset_index(drop=True) self.close = self.df_raw['close'] self.dif, self.dea, self.macd_bar = calc_macd(self.close) self.merged = merge_klines(self.df_raw) self.fractals = find_fractals(self.merged) self.bis = find_bis(self.fractals, min_k=self.MIN_BI_BARS) self._bi_index = {id(b): k for k, b in enumerate(self.bis)} self.segs = find_segs(self.bis) self.pivots_all = find_pivots(self.bis, self.segs) if self.CFG.get('drop_upgraded_pivots'): last_upg_idx = -1 for k, p in enumerate(self.pivots_all): if p.upgraded_level: last_upg_idx = k operative = [p for k, p in enumerate(self.pivots_all) if k > last_upg_idx and not p.upgraded_level] self.pivots = operative else: self.pivots = self.pivots_all self.trend = classify_trend(self.pivots) @property def n_bis(self): return len(self.bis) @property def n_segs(self): return len(self.segs) @property def n_pivots(self): return len(self.pivots) @property def n_pivots_all(self): return len(self.pivots_all) @property def n_trend_pivots(self): return count_trend_pivots(self.pivots) @property def has_upgraded_pivot(self): return any(p.upgraded_level for p in self.pivots_all) @staticmethod def _ds(ts): ts = pd.Timestamp(ts) if ts.hour == 0 and ts.minute == 0: return ts.strftime('%Y-%m-%d') return ts.strftime('%Y-%m-%d %H:%M') def _validate_abc(self, direction: str): if self.n_pivots < 2: return None last_piv = self.pivots[-1] prev_piv = self.pivots[-2] ratio = len(prev_piv.bis) / max(len(last_piv.bis), 1) if not (1 / 3 <= ratio <= 3): return None a_start_idx = prev_piv.bis[0] a_end_idx = last_piv.bis[0] if a_end_idx <= a_start_idx: return None c_start_idx = last_piv.bis[-1] + 1 if c_start_idx >= self.n_bis: return None return {'a_start_idx': a_start_idx, 'a_end_idx': a_end_idx, 'b_pivot': last_piv, 'c_start_idx': c_start_idx} def _validate_abc_consol(self, direction: str): if self.n_pivots < 1: return None piv = self.pivots[-1] a_end_idx = piv.bis[0] if a_end_idx <= 0: return None c_start_idx = piv.bis[-1] + 1 if c_start_idx >= self.n_bis: return None want = 'down' if direction == 'down' else 'up' a_start_idx = a_end_idx for k in range(a_end_idx - 1, -1, -1): a_start_idx = k if k >= 1 and self.bis[k].direction != want and self.bis[k-1].direction != want: a_start_idx = k + 1 break if a_start_idx >= a_end_idx: return None return {'a_start_idx': a_start_idx, 'a_end_idx': a_end_idx, 'b_pivot': piv, 'c_start_idx': c_start_idx} def _check_c_new_extreme(self, c_start_idx: int, direction: str): want = 'up' if direction == 'up' else 'down' c_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis) if self.bis[k].direction == want] if not c_bis: return False, None last_piv = self.pivots[-1] if self.pivots else None if direction == 'up': c_ext = max(b.end.price for b in c_bis) prior = (last_piv.gg if last_piv is not None else max((b.high for b in self.bis[:c_start_idx]), default=0.0)) return c_ext > prior * (1 - 0.001), c_ext else: c_ext = min(b.end.price for b in c_bis) prior = (last_piv.dd if last_piv is not None else min((b.low for b in self.bis[:c_start_idx]), default=1e18)) return c_ext < prior * (1 + 0.001), c_ext def _b_returns_to_zero(self, pivot) -> bool: dates = self.df_raw['date'] mask = (dates >= pivot.start_date) & (dates <= pivot.end_date) dif_b = self.dif[mask] dea_b = self.dea[mask] if len(dif_b) == 0 or len(dea_b) == 0: return False def near_zero(x): if x.min() <= 0 <= x.max(): return True return x.abs().min() < max(float(x.abs().max()), 1e-9) * 0.25 return near_zero(dif_b) and near_zero(dea_b) def detect_double_pullback_to_zero(self, window: int = 40) -> bool: n = len(self.dif) if n < 10: return False dif = self.dif.iloc[-min(window, n):].reset_index(drop=True) dea = self.dea.iloc[-min(window, n):].reset_index(drop=True) hist = self.macd_bar.iloc[-min(window, n):].reset_index(drop=True) scale = max(float(dif.abs().max()), float(dea.abs().max()), 1e-9) near = scale * 0.25 zero_pulls = [i for i in range(len(dif)) if abs(float(dif.iloc[i])) <= near and abs(float(dea.iloc[i])) <= near] if len(zero_pulls) < 2: return False def peak_between(left, right): vals = dif.iloc[left + 1:right] if len(vals) < 2: return None rel = int(vals.idxmax()) return float(dif.iloc[rel]), float(hist.iloc[max(left + 1, rel - 3):rel + 1].clip(lower=0).sum()) def trough_between(left, right): vals = dif.iloc[left + 1:right] if len(vals) < 2: return None rel = int(vals.idxmin()) return float(dif.iloc[rel]), float(hist.iloc[max(left + 1, rel - 3):rel + 1].clip(upper=0).abs().sum()) first_pull, second_pull = zero_pulls[-2], zero_pulls[-1] p1, p2 = peak_between(first_pull, second_pull), peak_between(second_pull, len(dif)) if p1 and p2 and p1[0] > 0 and p2[0] > 0 and p2[0] < p1[0] and p2[1] <= p1[1]: return True t1, t2 = trough_between(first_pull, second_pull), trough_between(second_pull, len(dif)) if t1 and t2 and t1[0] < 0 and t2[0] < 0 and t2[0] > t1[0] and t2[1] <= t1[1]: return True return False def divergence_strength_by_position(self) -> str: if len(self.dif) < 5: return 'strong_pullback' dif_now = float(self.dif.iloc[-1]) dif_abs_max = float(self.dif.abs().max()) if dif_abs_max <= 1e-9: return 'strong_pullback' if abs(dif_now) >= dif_abs_max * 0.85: return 'weak_pullback' return 'strong_pullback' def classify_post_divergence(self, direction: str) -> dict: if not self.pivots or self.n_bis < 2: return {'evolution': 'unknown', 'reason': '无中枢或笔不足'} last_piv = self.pivots[-1] after_idx = last_piv.bis[-1] + 1 def first_reversal_seg(want_dir): for s in self.segs: if not getattr(s, 'confirmed', True) or s.direction != want_dir: continue try: first_idx = self._bi_index[id(s.bis[0])] except KeyError: continue if first_idx >= after_idx: return s return None if direction == 'down': rebound = first_reversal_seg('up') if rebound is None: rebound = None for b in self.bis[after_idx:]: if b.direction == 'up': rebound = b; break if rebound is None: return {'evolution': 'unknown', 'reason': '无反弹笔'} if rebound.high < last_piv.zd: return {'evolution': 'case1_extend', 'reason': f'反弹高{rebound.high:.3f}<最后中枢ZD{last_piv.zd:.3f} → 第29课情况①未回中枢(最弱,宜尽快撤)'} if rebound.high >= last_piv.zd: return {'evolution': 'case2_3_turn', 'reason': f'反弹回到中枢(高{rebound.high:.3f}≥ZD{last_piv.zd:.3f}) → 第29课情况②③转折(可持有等三买)'} return {'evolution': 'case1_extend', 'reason': f'反弹未回中枢(高{rebound.high:.3f} last_piv.zg: return {'evolution': 'case1_extend', 'reason': f'回落低{pullback.low:.3f}>最后中枢ZG{last_piv.zg:.3f} → 第29课情况①未回中枢(最弱)'} if pullback.low <= last_piv.zg: return {'evolution': 'case2_3_turn', 'reason': f'回落回到中枢(低{pullback.low:.3f}≤ZG{last_piv.zg:.3f}) → 第29课情况②③转折'} return {'evolution': 'case1_extend', 'reason': f'回落未回中枢 → 偏向中枢扩展'} def macd_wrap_zero(self, window: int = 15) -> bool: """L88-90: 中阴阶段的MACD特征 —— 黄白线(DIF/DEA)绕0轴缠绕。 近window根K线中, DIF与DEA的绝对值大多压在历史摆幅的25%以内即视为缠绕。""" n = len(self.dif) if n < window + 5: return False dif = self.dif.iloc[-window:] dea = self.dea.iloc[-window:] scale = max(float(self.dif.abs().tail(120).max()), float(self.dea.abs().tail(120).max()), 1e-9) near = scale * 0.25 frac = float(((dif.abs() <= near) & (dea.abs() <= near)).mean()) return frac >= 0.6 def macd_clarity(self, window: int = 60) -> dict: """L50: 本级别MACD的"清晰度" —— 柱子面积幅度 + 黄白线分离度, 归一化打分。 清晰度高的级别其背驰判定更可靠; 多级别联立时应优先采信清晰级别的MACD结论。""" n = len(self.dif) if n < 10: return {'score': 0.0, 'label': '数据不足'} w = min(window, n) dif = self.dif.iloc[-w:] dea = self.dea.iloc[-w:] hist = self.macd_bar.iloc[-w:] px = max(float(self.close.iloc[-1]), 1e-9) bar_amp = float(hist.abs().mean()) / px # 柱子相对幅度 sep = float((dif - dea).abs().mean()) / px # 黄白线分离度 # 黄白线贴着0轴乱绕 → 不清晰 wrap_penalty = 0.5 if self.macd_wrap_zero() else 1.0 score = (bar_amp * 0.6 + sep * 0.4) * 1e3 * wrap_penalty label = '清晰' if score >= 1.0 else ('一般' if score >= 0.4 else '模糊(黄白线/柱子贴0轴)') return {'score': round(score, 3), 'label': label, 'bar_amp': round(bar_amp * 1e3, 3), 'sep': round(sep * 1e3, 3)} def in_zhongyin(self) -> dict: n = len(self.close) if n < 20 or not self.pivots: return {'in_zhongyin': False, 'boll_squeeze': False, 'reason': '数据不足'} ma = self.close.rolling(20).mean() sd = self.close.rolling(20).std() if ma.iloc[-1] and ma.iloc[-1] > 0: width = float((4 * sd.iloc[-1]) / ma.iloc[-1]) else: width = 0.0 wseries = (4 * sd / ma).dropna().tail(60) squeeze = bool(len(wseries) >= 20 and width <= wseries.quantile(0.30)) osc = self.zhongshu_oscillation_monitor() macd_wrap = self.macd_wrap_zero() dbl_pull = self.detect_double_pullback_to_zero() mode = self.CFG.get('zhongyin_macd_mode', 'off') if mode == 'and': in_zy = (not osc.get('alert', False)) and squeeze and macd_wrap elif mode == 'or': in_zy = (not osc.get('alert', False)) and (squeeze or macd_wrap) else: in_zy = (not osc.get('alert', False)) and squeeze reason = (f"BOLL带宽{width:.3f}{'(收口→中阴)' if squeeze else '(开口)'}; " f"MACD黄白线{'绕0轴缠绕(L88-90中阴特征)' if macd_wrap else '已展开'}" f"{'; 双回拉0轴(L89: 中阴结束转折预备)' if dbl_pull else ''}; {osc.get('reason','')}") return {'in_zhongyin': in_zy, 'boll_squeeze': squeeze, 'macd_wrap_zero': macd_wrap, 'double_pullback_zero': dbl_pull, 'reason': reason} def zhongshu_oscillation_monitor(self) -> dict: if not self.pivots or self.n_bis < 1: return {'alert': False, 'direction': '', 'reason': '无中枢'} last_piv = self.pivots[-1] cur = self.bis[-1] if cur.low > last_piv.zg: return {'alert': True, 'direction': 'up', 'reason': f'第92课: 末笔({cur.low:.3f}~{cur.high:.3f})已离开中枢上沿ZG{last_piv.zg:.3f} → 向上变盘预警'} if cur.high < last_piv.zd: return {'alert': True, 'direction': 'down', 'reason': f'第92课: 末笔({cur.low:.3f}~{cur.high:.3f})已离开中枢下沿ZD{last_piv.zd:.3f} → 向下变盘预警'} return {'alert': False, 'direction': '', 'reason': '末笔仍在中枢区间内, 中枢震荡延续'} def bottom_construction_state(self) -> str: has_b1 = self.detect_b1() is not None has_b3 = self.detect_b3() is not None has_s3 = self.detect_s3() is not None if has_s3: return 'failed' if has_b3: return 'completed' if has_b1: return 'constructing' return 'none' def _seg_index_range(self, seg): m = self._bi_index try: return m[id(seg.bis[0])], m[id(seg.bis[-1])] except KeyError: return None def _bi_exit_pullback_fallback(self, pivot, exit_dir: str, pull_dir: str): pe = pivot.bis[-1] leave = pull = None for k in range(pe + 1, self.n_bis): b = self.bis[k] if leave is None: if b.direction == exit_dir: leave = b continue if b.direction == pull_dir: pull = b break if leave is None or pull is None: return None return leave, pull def _last_exit_pullback_segments(self, pivot, exit_dir: str, pull_dir: str): pivot_end = pivot.bis[-1] seq = [] for s in (self.segs or []): if not getattr(s, 'confirmed', True): continue rng = self._seg_index_range(s) if rng is None: continue first_idx, last_idx = rng if last_idx < pivot_end: continue seq.append((s, first_idx, last_idx)) first_only = self.CFG.get('b3s3_first_pullback_only') if first_only: for i in range(len(seq) - 1): leave, lf, _ = seq[i] pull = seq[i + 1][0] if leave.direction == exit_dir and pull.direction == pull_dir and lf >= pivot_end: return leave, pull else: for i in range(len(seq) - 1, 0, -1): pull, _, _ = seq[i] leave, _, _ = seq[i - 1] if leave.direction == exit_dir and pull.direction == pull_dir: return leave, pull return self._bi_exit_pullback_fallback(pivot, exit_dir, pull_dir) def assess_divergence(self, a_start, a_end, c_start, c_end, direction: str) -> DivergenceGrade: dates = self.df_raw['date'] n_tp = self.n_trend_pivots is_trend_div = n_tp >= 2 a_area = macd_area_between(a_start, a_end, self.macd_bar, dates, direction) c_area = macd_area_between(c_start, c_end, self.macd_bar, dates, direction) if a_area <= 1e-9: return DivergenceGrade('NONE', False, False, 0.0, a_area, c_area, 0.0, 0.0, direction, 'A段MACD面积为0,无可比基准', is_trend_divergence=is_trend_div, n_trend_pivots=n_tp) ratio = c_area / a_area area_ok = ratio < self.DIVERGE_RATIO TOL = self.DIF_TOLERANCE if direction == 'up': a_dif = dif_extreme_in(a_start, a_end, self.dif, dates, 'peak') c_dif = dif_extreme_in(c_start, c_end, self.dif, dates, 'peak') dif_ok = c_dif < a_dif * (1 - TOL) else: a_dif = dif_extreme_in(a_start, a_end, self.dif, dates, 'trough') c_dif = dif_extreme_in(c_start, c_end, self.dif, dates, 'trough') dif_ok = c_dif > a_dif * (1 - TOL) ext_label = 'DIF峰' if direction == 'up' else 'DIF谷' div_kind = '趋势背驰' if is_trend_div else '盘整背驰' if area_ok and dif_ok: grade = 'STRONG' reason = f'标准{div_kind}(面积+DIF均满足)|A面积:{a_area:.4f} C面积:{c_area:.4f}(比值{ratio:.1%}<{self.DIVERGE_RATIO:.0%})|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}|{n_tp}中枢' elif area_ok and not dif_ok: grade = 'WEAK' reason = f'{div_kind}信号(面积触发)|A面积:{a_area:.4f} C面积:{c_area:.4f}(比值{ratio:.1%}<{self.DIVERGE_RATIO:.0%})|{n_tp}中枢' elif dif_ok and not area_ok: grade = 'WEAK' reason = f'{div_kind}信号(DIF触发)|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}|{n_tp}中枢' else: grade = 'NONE' reason = f'无背驰(两判据均不满足)|面积比{ratio:.1%}>={self.DIVERGE_RATIO:.0%}|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}' return DivergenceGrade(grade, area_ok, dif_ok, ratio, a_area, c_area, a_dif, c_dif, direction, reason, is_trend_divergence=is_trend_div, n_trend_pivots=n_tp) def _find_prev_b1(self) -> tuple: if not self.pivots: return (None, '') last_piv = self.pivots[-1] pivot_first_bi_idx = last_piv.bis[0] if pivot_first_bi_idx <= 0: return (None, '') downs = [] for k in range(pivot_first_bi_idx - 1, -1, -1): b = self.bis[k] downs.append(b) if b.direction == 'up' and k - 1 >= 0 and self.bis[k-1].direction == 'down': if len(downs) >= 4: break down_bis = [b for b in downs if b.direction == 'down'] if not down_bis: return (None, '') b1 = min(down_bis, key=lambda b: b.end.price) return (b1.end.price, self._ds(b1.end.date)) def _find_prev_b2(self) -> tuple: b1_price, b1_date_str = self._find_prev_b1() if b1_price is None or not self.pivots: return (None, '') b1_date = pd.Timestamp(b1_date_str) last_piv = self.pivots[-1] right_bi_idx = last_piv.bis[-1] + 1 for b in self.bis[:right_bi_idx]: if b.direction != 'down': continue if b.end.date <= b1_date: continue if b.end.price > b1_price + 1e-9: return (b.end.price, self._ds(b.end.date)) return (None, '') def _find_prev_s1(self) -> tuple: if not self.pivots: return (None, '') last_piv = self.pivots[-1] pivot_first_bi_idx = last_piv.bis[0] if pivot_first_bi_idx <= 0: return (None, '') ups = [] for k in range(pivot_first_bi_idx - 1, -1, -1): b = self.bis[k] ups.append(b) if b.direction == 'down' and k - 1 >= 0 and self.bis[k-1].direction == 'up': if len(ups) >= 4: break up_bis = [b for b in ups if b.direction == 'up'] if not up_bis: return (None, '') s1 = max(up_bis, key=lambda b: b.end.price) return (s1.end.price, self._ds(s1.end.date)) def _find_prev_s2(self) -> tuple: s1_price, s1_date_str = self._find_prev_s1() if s1_price is None or not self.pivots: return (None, '') s1_date = pd.Timestamp(s1_date_str) last_piv = self.pivots[-1] right_bi_idx = last_piv.bis[-1] + 1 for b in self.bis[:right_bi_idx]: if b.direction != 'up': continue if b.end.date <= s1_date: continue if b.end.price < s1_price - 1e-9: return (b.end.price, self._ds(b.end.date)) return (None, '') def detect_b1(self) -> Optional[Signal]: if self.n_bis < 5: return None cur = self.bis[-1] if cur.direction != 'down': return None dif_now = float(self.dif.iloc[-1]) if dif_now >= 0: return None trend_ok = (self.n_pivots >= 2 and self.trend == 'down_trend') consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1) if not (trend_ok or consol_ok): return None abc = self._validate_abc('down') if abc is None and consol_ok: abc = self._validate_abc_consol('down') if abc is None: return None c_ok, c_low = self._check_c_new_extreme(abc['c_start_idx'], 'down') if not c_ok: return None last_piv = self.pivots[-1] a_down = [self.bis[k] for k in range(abc['a_start_idx'], abc['a_end_idx']) if self.bis[k].direction == 'down'] c_down = [self.bis[k] for k in range(abc['c_start_idx'], self.n_bis) if self.bis[k].direction == 'down'] if not a_down or not c_down: return None dg = self.assess_divergence(a_down[0].start.date, a_down[-1].end.date, c_down[0].start.date, c_down[-1].end.date, 'down') if dg.grade == 'NONE': return None div_kind = '趋势底背驰' if dg.is_trend_divergence else '盘整底背驰(第27课)' b_zero = self._b_returns_to_zero(last_piv) dbl_pull = self.detect_double_pullback_to_zero() pos_strength = self.divergence_strength_by_position() post_evo = self.classify_post_divergence('down') a_low_bi = min(a_down, key=lambda b: b.end.price) c_low_bi = min(c_down, key=lambda b: b.end.price) return Signal(kind='B1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]), reason=f'{div_kind}一买|{self.n_pivots}中枢|ABC三段{"+B回0轴" if b_zero else ""}|{dg.reason}|DIF={dif_now:.4f}<0', pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend, extras={'a_seg': (a_down[0].start.date, a_down[-1].end.date, a_down[0].start.price, a_down[-1].end.price), 'diverge_grade': dg.grade, 'a_low': float(a_low_bi.end.price), 'a_low_date': self._ds(a_low_bi.end.date), 'b1_price': float(cur.end.price), 'b1_date': self._ds(cur.end.date), 'macd_grade': dg.grade, 'macd_area_ratio': dg.area_ratio, 'dif_ok': dg.dif_ok, 'area_ok': dg.area_ok, 'b_returns_zero': b_zero, 'double_pullback': dbl_pull, 'pos_strength': pos_strength, 'post_evolution': post_evo['evolution'], 'c_new_low': c_low, 'c_new_low_date': self._ds(c_low_bi.end.date), 'n_trend_pivots': self.n_trend_pivots, 'price_date': self._ds(cur.end.date), 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '', 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '', 'pivot_start_date': self._ds(last_piv.start_date), 'pivot_end_date': self._ds(last_piv.end_date)}, diverge_grade=dg) def detect_b2(self) -> Optional[Signal]: if self.n_bis < 4: return None cur = self.bis[-1] if cur.direction != 'down': return None prev_downs = [b for b in self.bis[:-1] if b.direction == 'down'] if not prev_downs: return None prev = prev_downs[-1] if not (cur.low >= prev.low and cur.end.price > prev.end.price): return None cur_price = float(self.close.iloc[-1]) if cur_price < cur.end.price * (1 - self.PIVOT_TOLERANCE): return None if self.CFG.get('b2s2_anchor_to_first'): b1_anchor, _ = self._find_prev_b1() if b1_anchor is None: return None if cur.end.price < b1_anchor - 1e-9: return None dif_now = float(self.dif.iloc[-1]) if self.CFG.get('b2_macd_zero_pullback'): look = self.dif.tail(12) crossed_up = bool((look > 0).any()) if not crossed_up: return None if dif_now < -self.DIF_TOLERANCE: return None last_piv = self.pivots[-1] if self.pivots else None b1_price, b1_date = prev.end.price, self._ds(prev.end.date) return Signal(kind='B2', date=self.df_raw['date'].iloc[-1], price=cur_price, reason=f'二买:一买后回踩不破|当前低{cur.end.price:.3f}>一买{b1_price:.3f}|二买不以背驰为成立条件(第21课)', pivot_zg=None, pivot_zd=None, macd_ratio=None, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend, extras={'prev_low': prev.end.price, 'cur_low': cur.end.price, 'cur_low_date': self._ds(cur.end.date), 'prev_low_date': self._ds(prev.end.date), 'b1_price': b1_price, 'b1_date': b1_date, 'price_date': self._ds(cur.end.date), 'context_pivot_zg': last_piv.zg if last_piv else None, 'context_pivot_zd': last_piv.zd if last_piv else None, 'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '', 'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '', 'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '', 'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''}, diverge_grade=None) def detect_b3(self) -> Optional[Signal]: if self.n_bis < 5 or self.n_pivots < 1: return None late_trend_b3 = self.n_trend_pivots >= 2 last_piv = self.pivots[-1] zg, zd = last_piv.zg, last_piv.zd piv_height = zg - zd cur = self.bis[-1] if cur.direction != 'up': return None pair = self._last_exit_pullback_segments(last_piv, 'up', 'down') if pair is None: return None exit_seg, pull_seg = pair if not (exit_seg.low <= zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > zg): return None if pull_seg.low < zg * (1 - self.PIVOT_TOLERANCE): return None leaves_pivot = pull_seg.low >= zg cur_price = float(self.close.iloc[-1]) if cur_price <= zg: return None ex_amp = exit_seg.high - exit_seg.low if piv_height > 0 and ex_amp < piv_height * 0.5: return None if len(last_piv.bis) < 3: return None confirm_txt = '回踩离枢确认(新中枢生成)' if leaves_pivot else '回踩贴ZG(容差内,新中枢待确认)' b1_price, b1_date = self._find_prev_b1() b2_price, b2_date = self._find_prev_b2() warn_txt = '|第二个以上同向中枢,实盘宜改用低级别一买' if late_trend_b3 else '' return Signal(kind='B3', date=self.df_raw['date'].iloc[-1], price=cur_price, reason=f'标准三买|ZG={zg:.3f},ZD={zd:.3f}|线段离枢:{exit_seg.low:.3f}→{exit_seg.high:.3f}(幅度{ex_amp:.3f})|线段回试低{pull_seg.low:.3f}|{confirm_txt}|当前{cur_price:.3f}>ZG{warn_txt}', pivot_zg=zg, pivot_zd=zd, macd_ratio=None, dif_value=float(self.dif.iloc[-1]), n_pivots=self.n_pivots, trend=self.trend, extras={'exit_seg': (exit_seg.low, exit_seg.high), 'pull_low': pull_seg.low, 'pull_low_date': self._ds(pull_seg.end.date), 'exit_start_date': self._ds(exit_seg.start.date), 'exit_end_date': self._ds(exit_seg.end.date), 'b1_price': b1_price, 'b1_date': b1_date, 'b2_price': b2_price, 'b2_date': b2_date, 'piv_bi_count': len(last_piv.bis), 'leaves_pivot': leaves_pivot, 'late_trend_b3': late_trend_b3, 'price_date': self._ds(self.df_raw['date'].iloc[-1]), 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '', 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '', 'pivot_start_date': self._ds(last_piv.start_date), 'pivot_end_date': self._ds(last_piv.end_date)}, diverge_grade=None) def detect_s1(self) -> Optional[Signal]: if self.n_bis < 5: return None cur = self.bis[-1] if cur.direction != 'up': return None trend_ok = (self.n_pivots >= 2 and self.trend == 'up_trend') consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1) if not (trend_ok or consol_ok): return None abc = self._validate_abc('up') if abc is None and consol_ok: abc = self._validate_abc_consol('up') if abc is None: return None last_piv = self.pivots[-1] a_start_idx = abc['a_start_idx']; a_end_idx = abc['a_end_idx'] if a_end_idx <= a_start_idx: return None a_up_bis = [self.bis[k] for k in range(a_start_idx, a_end_idx) if self.bis[k].direction == 'up'] if not a_up_bis: return None a_high = max(b.end.price for b in a_up_bis) c_start_idx = last_piv.bis[-1] + 1 c_up_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis) if self.bis[k].direction == 'up'] if not c_up_bis: return None c_high = max(b.end.price for b in c_up_bis) if c_high <= a_high: return None a_high_bi = max(a_up_bis, key=lambda b: b.end.price) dg = self.assess_divergence(a_up_bis[0].start.date, a_up_bis[-1].end.date, c_up_bis[0].start.date, c_up_bis[-1].end.date, 'up') if dg.grade == 'NONE': return None b_zero = self._b_returns_to_zero(last_piv) dbl_pull = self.detect_double_pullback_to_zero() pos_strength = self.divergence_strength_by_position() post_evo = self.classify_post_divergence('up') dif_now = float(self.dif.iloc[-1]) c_high_bi = max(c_up_bis, key=lambda b: b.end.price) return Signal(kind='S1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]), reason=f'一卖|上涨趋势{self.n_pivots}中枢|价创新高C{c_high:.3f}>A段高{a_high:.3f}{"+B回0轴" if b_zero else ""}|{dg.reason}', pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend, extras={'a_high': a_high, 'c_high': c_high, 'a_area': dg.a_area, 'c_area': dg.c_area, 'diverge_grade': dg.grade, 'a_high_date': self._ds(a_high_bi.end.date), 'b_returns_zero': b_zero, 'double_pullback': dbl_pull, 'pos_strength': pos_strength, 'post_evolution': post_evo['evolution'], 'c_high_date': self._ds(c_high_bi.end.date), 'price_date': self._ds(cur.end.date), 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '', 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '', 'pivot_start_date': self._ds(last_piv.start_date), 'pivot_end_date': self._ds(last_piv.end_date)}, diverge_grade=dg) def detect_s2(self) -> Optional[Signal]: if self.n_bis < 4: return None cur = self.bis[-1] if cur.direction != 'up': return None prev_ups = [b for b in self.bis[:-1] if b.direction == 'up'] if not prev_ups: return None prev = prev_ups[-1] if cur.high < prev.high and cur.end.price < prev.end.price: cur_price = float(self.close.iloc[-1]) if cur_price > cur.end.price * (1 + self.PIVOT_TOLERANCE): return None if self.CFG.get('b2s2_anchor_to_first'): s1_anchor, _ = self._find_prev_s1() if s1_anchor is None: return None if cur.end.price > s1_anchor + 1e-9: return None dif_now = float(self.dif.iloc[-1]) last_piv = self.pivots[-1] if self.pivots else None s1_price, s1_date = prev.end.price, self._ds(prev.end.date) return Signal(kind='S2', date=self.df_raw['date'].iloc[-1], price=cur_price, reason=f'二卖:一卖后反弹不破|当前高{cur.end.price:.3f}<一卖{s1_price:.3f}|二卖不以背驰为成立条件(第21课)', pivot_zg=None, pivot_zd=None, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend, extras={'prev_high': prev.end.price, 'cur_high': cur.end.price, 'cur_high_date': self._ds(cur.end.date), 'prev_high_date': self._ds(prev.end.date), 's1_price': s1_price, 's1_date': s1_date, 'price_date': self._ds(cur.end.date), 'context_pivot_zg': last_piv.zg if last_piv else None, 'context_pivot_zd': last_piv.zd if last_piv else None, 'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '', 'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '', 'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '', 'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''}, diverge_grade=None) return None def detect_s3(self) -> Optional[Signal]: if self.n_bis < 5 or self.n_pivots < 1: return None last_piv = self.pivots[-1] zg, zd = last_piv.zg, last_piv.zd if len(self.bis) < 3: return None cur = self.bis[-1] if cur.direction != 'down': return None s1_price, s1_date = self._find_prev_s1() s2_price, s2_date = self._find_prev_s2() base_dates = {'price_date': self._ds(self.df_raw['date'].iloc[-1]), 's1_price': s1_price, 's1_date': s1_date, 's2_price': s2_price, 's2_date': s2_date, 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '', 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '', 'pivot_start_date': self._ds(last_piv.start_date), 'pivot_end_date': self._ds(last_piv.end_date)} pair = self._last_exit_pullback_segments(last_piv, 'down', 'up') if pair is None: return None exit_seg, pull_seg = pair if not (exit_seg.high >= zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < zd): return None if pull_seg.high > zd * (1 + self.PIVOT_TOLERANCE): return None if float(self.close.iloc[-1]) >= zd: return None return Signal(kind='S3', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]), reason=f'标准三卖|ZD={zd:.3f}|线段离枢低{exit_seg.low:.3f} Optional[Signal]: for fn in [self.detect_s1, self.detect_s2, self.detect_s3]: sig = fn() if sig is not None: return sig for fn in [self.detect_b3, self.detect_b2, self.detect_b1]: sig = fn() if sig is not None: return sig return None def get_all_signals(self) -> list: all_sigs = [] for fn in [self.detect_b1, self.detect_b2, self.detect_b3, self.detect_s1, self.detect_s2, self.detect_s3]: sig = fn() if sig is not None: all_sigs.append(sig) return all_sigs def l36_segment_note(self) -> str: """L36 结合律: 走势分解的唯一性靠结合律保证 —— a+A+b+B+c 的划分中, 同一段 K线不能既归前段又归后段。本引擎线段划分采用特征序列分型(标准缠论)处理 包含关系, 分型一旦确认即锁定段的归属, 等价于结合律的程序化执行。 该函数对当前末端给出"是否存在划分歧义"的提示。""" if len(self.segs) < 2: return '第36课: 线段不足2段, 无划分歧义问题' last = self.segs[-1] n_last = len(getattr(last, 'bis', []) or []) if n_last < 3: return (f'第36课: 末段仅{n_last}笔(<3), 末端划分尚未唯一确认 —— ' f'当下操作应按两种归属做完全分类预案, 等待特征序列分型锁定') return '第36课: 末段≥3笔且特征序列分型已锁定, 当前划分唯一, 无歧义' def diagnose(self) -> dict: cur_price = float(self.close.iloc[-1]) if len(self.close) else 0.0 last = self.bis[-1] if self.bis else None out = {k: [] for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3')} def add(k, ok, msg): out[k].append(('✓' if ok else '✗') + ' ' + msg) add('B1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5') add('B1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2') add('B1', self.trend == 'down_trend', f'当前走势={self.trend}, B1要求下跌趋势') add('B1', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B1要求向下') add('B1', float(self.dif.iloc[-1]) < 0 if len(self.dif) else False, f'DIF={float(self.dif.iloc[-1]):.4f}, B1要求DIF<0') abc_down = self._validate_abc('down') add('B1', abc_down is not None, 'A/B/C三段背驰结构成立') if abc_down is not None: c_ok, c_low = self._check_c_new_extreme(abc_down['c_start_idx'], 'down') add('B1', c_ok, f'C段创新低{"" if c_low is None else f"({c_low:.3f})"}') add('B2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4') add('B2', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B2要求回踩向下') prev_downs = [b for b in self.bis[:-1] if b.direction == 'down'] if last else [] add('B2', bool(prev_downs), '存在同一轮前一个下跌低点作为一买锚') if last and prev_downs: prev = prev_downs[-1] add('B2', last.low >= prev.low and last.end.price > prev.end.price, f'回踩不创新低: 本次低{last.end.price:.3f} > 一买/前低{prev.end.price:.3f}') add('B2', cur_price >= last.end.price * (1 - self.PIVOT_TOLERANCE), f'现价{cur_price:.3f}未跌破B2回踩锚{last.end.price:.3f}; 跌破则二买失效') add('B3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1') add('B3', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, B3要求向上确认') if self.pivots: p = self.pivots[-1] pair = self._last_exit_pullback_segments(p, 'up', 'down') add('B3', pair is not None, '存在已确认线段级别的向上离枢 + 向下回试') if pair is not None: exit_seg, pull_seg = pair add('B3', exit_seg.low <= p.zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > p.zg, f'离枢线段突破ZG: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZG={p.zg:.3f}') add('B3', pull_seg.low >= p.zg * (1 - self.PIVOT_TOLERANCE), f'回试低点{pull_seg.low:.3f}不破ZG={p.zg:.3f}') add('B3', cur_price > p.zg, f'现价{cur_price:.3f}站上ZG={p.zg:.3f}') add('S1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5') add('S1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2') add('S1', self.trend == 'up_trend', f'当前走势={self.trend}, S1要求上涨趋势') add('S1', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S1要求向上') abc_up = self._validate_abc('up') add('S1', abc_up is not None, 'A/B/C三段顶背驰结构成立') if abc_up is not None: c_ok, c_high = self._check_c_new_extreme(abc_up['c_start_idx'], 'up') add('S1', c_ok, f'C段创新高{"" if c_high is None else f"({c_high:.3f})"}') add('S2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4') add('S2', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S2要求反弹向上') prev_ups = [b for b in self.bis[:-1] if b.direction == 'up'] if last else [] add('S2', bool(prev_ups), '存在同一轮前一个上涨高点作为一卖锚') if last and prev_ups: prev = prev_ups[-1] add('S2', last.high < prev.high and last.end.price < prev.end.price, f'反弹不创新高: 本次高{last.end.price:.3f} < 一卖/前高{prev.end.price:.3f}') add('S2', cur_price <= last.end.price * (1 + self.PIVOT_TOLERANCE), f'现价{cur_price:.3f}未重新升破S2反弹锚{last.end.price:.3f}; 升破则二卖失效') add('S3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1') add('S3', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, S3要求向下确认') if self.pivots: p = self.pivots[-1] pair = self._last_exit_pullback_segments(p, 'down', 'up') add('S3', pair is not None, '存在已确认线段级别的向下离枢 + 向上回抽') if pair is not None: exit_seg, pull_seg = pair add('S3', exit_seg.high >= p.zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < p.zd, f'离枢线段跌破ZD: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZD={p.zd:.3f}') add('S3', pull_seg.high <= p.zd * (1 + self.PIVOT_TOLERANCE), f'回抽高点{pull_seg.high:.3f}不破ZD={p.zd:.3f}') add('S3', cur_price < p.zd, f'现价{cur_price:.3f}跌破ZD={p.zd:.3f}') return out class SameLevelDecomposition: def __init__(self, analyzer: 'ChanAnalyzer'): self.an = analyzer self.segs = analyzer.segs def current_phase(self) -> dict: if len(self.segs) < 2: return {'seg_dir': '', 'stage': 'unknown', 'action': 'WATCH', 'reason': '线段不足, 无法做同级别分解'} last = self.segs[-1] prev = self.segs[-2] seg_dir = last.direction if seg_dir == 'up': stage = 'up_run' prev_up = None for s in reversed(self.segs[:-1]): if s.direction == 'up': prev_up = s; break if prev_up is None: return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD', 'reason': '向上段运作中(无前向上段可比), 持有'} if last.high <= prev_up.high: return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL', 'reason': f'向上段不创新高({last.high:.3f}≤前高{prev_up.high:.3f}) → 先卖(第38课)'} dg = self.an.assess_divergence(prev_up.start.date, prev_up.end.date, last.start.date, last.end.date, 'up') if dg.grade != 'NONE': return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL', 'reason': f'向上段创新高但盘整背驰({dg.grade}) → 卖(第38课)'} return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD', 'reason': '向上段创新高且不背驰 → 持有(第38课)'} else: stage = 'down_run' prev_down = None for s in reversed(self.segs[:-1]): if s.direction == 'down': prev_down = s; break if prev_down is None: return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH', 'reason': '向下段运作中(无前向下段可比), 观望等买点'} if last.low >= prev_down.low: return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY', 'reason': f'向下段不创新低({last.low:.3f}≥前低{prev_down.low:.3f}) → 买入(第38课)'} dg = self.an.assess_divergence(prev_down.start.date, prev_down.end.date, last.start.date, last.end.date, 'down') if dg.grade != 'NONE': return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY', 'reason': f'向下段创新低但盘整背驰({dg.grade}) → 买入(第38课)'} return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH', 'reason': '向下段创新低且不背驰 → 观望等下跌背驰(第38课)'} class BottomTracker: def __init__(self): self.state = 'none' def update(self, analyzer: 'ChanAnalyzer') -> str: snap = analyzer.bottom_construction_state() if self.state in ('none', 'failed', 'completed'): if snap == 'constructing': self.state = 'constructing' elif snap == 'completed': self.state = 'completed' elif snap == 'failed': self.state = 'failed' else: self.state = 'none' elif self.state == 'constructing': if snap == 'completed': self.state = 'completed' elif snap == 'failed': self.state = 'failed' return self.state