| """ |
| 缠论引擎 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 |
| |
| 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 = [] |
| 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]) |
| |
| |
| 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]) |
| |
| 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, |
| |
| |
| |
| |
| 'zhongyin_macd_mode': 'or', |
| } |
|
|
| 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}<ZD{last_piv.zd:.3f}) → 偏向中枢扩展'} |
| else: |
| pullback = first_reversal_seg('down') |
| if pullback is None: |
| pullback = None |
| for b in self.bis[after_idx:]: |
| if b.direction == 'down': |
| pullback = b; break |
| if pullback is None: |
| return {'evolution': 'unknown', 'reason': '无回落笔'} |
| if pullback.low > 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 |
| |
| 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}<ZD|线段回抽高{pull_seg.high:.3f}未过ZD', |
| pivot_zg=zg, pivot_zd=zd, dif_value=float(self.dif.iloc[-1]), |
| n_pivots=self.n_pivots, trend=self.trend, extras=base_dates, diverge_grade=None) |
|
|
| def get_signal(self) -> 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 |
|
|