alphax/pa_engine.py
2026-05-13 22:32:50 +08:00

784 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
价格行为学(PA)引擎 — 动K/静K、供需区Q评分、连续K加速、起爆点判定
核心概念:
- 动K(Dynamic Candle):实体占比>70% + 振幅>1.5×ATR → 强方向性力量
- 静K(Static Candle):实体占比<40% + 振幅<0.8×ATR → 犹豫/平衡/蓄力
- 供需区由动K+静K群组成Q评分≥7才是高质量区
- 起爆点静K群后出现动K = 从蓄力到爆发的转折
- 连续K高低点不断抬高/降低 = 趋势加速段
"""
import pandas as pd
import numpy as np
from typing import List, Dict, Optional, Tuple
from config_loader import (
dynamic_k_thresholds,
static_k_thresholds,
zone_params,
ignition_params,
continuous_k_params,
exhaustion_params,
entry_point_params,
)
# ==================== 动K/静K识别 ====================
def classify_candles(df: pd.DataFrame, atr: float) -> List[Dict]:
"""
对每根K线做动K/静K分类
返回: [{"index": i, "type": "dynamic"/"static"/"neutral", "direction": 1/-1/0, ...}]
"""
dynamic_body_ratio_min, dynamic_atr_ratio_min = dynamic_k_thresholds()
static_body_ratio_max, static_atr_ratio_max = static_k_thresholds()
results = []
for i in range(len(df)):
o = float(df["open"].iloc[i])
c = float(df["close"].iloc[i])
h = float(df["high"].iloc[i])
l = float(df["low"].iloc[i])
v = float(df["volume"].iloc[i])
body = abs(c - o)
total_range = h - l
if total_range <= 0 or l <= 0 or atr <= 0:
results.append({"index": i, "type": "neutral", "direction": 0,
"body_ratio": 0, "swing_ratio": 0, "volume": v})
continue
body_ratio = body / total_range # 实体占比
swing_ratio = total_range / l # 振幅/低价 ≈ 涨跌幅
atr_ratio = total_range / atr # 振幅/ATR
# 动K实体占比>70% + 振幅>1.5×ATR
if body_ratio > dynamic_body_ratio_min and atr_ratio > dynamic_atr_ratio_min:
direction = 1 if c > o else -1
results.append({"index": i, "type": "dynamic", "direction": direction,
"body_ratio": body_ratio, "swing_ratio": swing_ratio,
"atr_ratio": atr_ratio, "volume": v})
# 静K实体占比<40% + 振幅<0.8×ATR
elif body_ratio < static_body_ratio_max and atr_ratio < static_atr_ratio_max:
results.append({"index": i, "type": "static", "direction": 0,
"body_ratio": body_ratio, "swing_ratio": swing_ratio,
"atr_ratio": atr_ratio, "volume": v})
else:
direction = 1 if c > o else -1 if c < o else 0
results.append({"index": i, "type": "neutral", "direction": direction,
"body_ratio": body_ratio, "swing_ratio": swing_ratio,
"atr_ratio": atr_ratio, "volume": v})
return results
def calc_atr(df: pd.DataFrame, period: int = 14) -> float:
"""计算ATR"""
if df is None or len(df) < period + 1:
return 0.0
tr = pd.concat([
df["high"] - df["low"],
abs(df["high"] - df["close"].shift(1)),
abs(df["low"] - df["close"].shift(1)),
], axis=1).max(axis=1)
atr = tr.rolling(period).mean().iloc[-1]
return float(atr) if not pd.isna(atr) else 0.0
# ==================== 供需区 + Q评分 ====================
def find_supply_demand_zones(df: pd.DataFrame, candles_class: List[Dict],
atr: float, lookback: int = 50) -> List[Dict]:
"""
识别结构化供需区动K+静K群+动K确认并计算Q评分
供给区模式阴动K(方向-1) → 静K群(2-5根) → 阳动K确认(方向+1)离开
需求区模式阳动K(方向+1) → 静K群(2-5根) → 阴动K确认(方向-1)离开
返回: [{"type": "supply"/"demand", "top": float, "btm": float,
"q_score": float, "freshness": int, ...}]
"""
cfg_lookback, min_static_count, _ = zone_params()
lookback = cfg_lookback if cfg_lookback else lookback
zones = []
if not candles_class or atr <= 0:
return zones
# 只看最近lookback根K线
start_idx = max(0, len(candles_class) - lookback)
for i in range(start_idx, len(candles_class)):
c = candles_class[i]
if c["type"] != "dynamic":
continue
# 检查后面是否有静K群+确认动K
base_dir = c["direction"] # 起始动K方向
# 寻找静K群紧随起始动K之后的2-5根静K
static_start = i + 1
static_count = 0
static_end = static_start
for j in range(static_start, min(static_start + 5, len(candles_class))):
if candles_class[j]["type"] == "static":
static_count += 1
static_end = j + 1
else:
break
if static_count < min_static_count: # 需要至少N根静K
continue
# 寻找确认动K静K群之后的动K
confirm_idx = None
for j in range(static_end, min(static_end + 3, len(candles_class))):
if candles_class[j]["type"] == "dynamic":
confirm_idx = j
break
if confirm_idx is None:
continue
confirm_dir = candles_class[confirm_idx]["direction"]
# 判断区类型
# 供给区起始是阴动K确认是阳动K离开向上
# 需求区起始是阳动K确认是阴动K离开向下
if base_dir == -1 and confirm_dir == 1:
zone_type = "supply"
elif base_dir == 1 and confirm_dir == -1:
zone_type = "demand"
else:
continue # 方向不匹配
# 计算区的范围
# 供给区从静K群最低点到起始动K高点
# 需求区从起始动K低点到静K群最高点
region_indices = [i] + list(range(static_start, static_end))
region_highs = [float(df["high"].iloc[ri]) for ri in region_indices]
region_lows = [float(df["low"].iloc[ri]) for ri in region_indices]
if zone_type == "supply":
zone_top = max(region_highs)
zone_btm = min(region_lows)
else:
zone_top = max(region_highs) # 需求区top = 阳动K高点+静K高点
zone_btm = min(region_lows) # 需求区btm = 最低点
zone_height = zone_top - zone_btm
if zone_height <= 0:
continue
# 离去强度确认动K的离开幅度 / 区高度
confirm_price = float(df["close"].iloc[confirm_idx])
if zone_type == "supply":
leave_dist = confirm_price - zone_top # 离开供给区向上
else:
leave_dist = zone_btm - confirm_price # 离开需求区向下
leave_ratio = abs(leave_dist) / zone_height if zone_height > 0 else 0
# Q评分0-10分
q_score = _calc_q_score(
zone_type=zone_type,
base_speed=static_count, # 静K数量基底速度
freshness=_calc_freshness(df, zone_top, zone_btm, i, zone_type),
age=len(candles_class) - i, # K线年龄
leave_ratio=leave_ratio,
reaction_strength=_calc_reaction_strength(df, zone_type, zone_top, zone_btm, i),
)
zones.append({
"type": zone_type,
"top": round(zone_top, 6),
"btm": round(zone_btm, 6),
"q_score": q_score,
"leave_ratio": round(leave_ratio, 2),
"base_index": i,
"confirm_index": confirm_idx,
"static_count": static_count,
"age": len(candles_class) - i,
})
# 去重同价位区域保留Q评分最高的
deduped = {}
for z in zones:
key = (z["type"], round(z["btm"], 4), round(z["top"], 4))
if key not in deduped or z["q_score"] > deduped[key]["q_score"]:
deduped[key] = z
return sorted(deduped.values(), key=lambda z: z["q_score"], reverse=True)
def _calc_q_score(zone_type, base_speed, freshness, age, leave_ratio, reaction_strength):
"""
Q质量评分0-10分
维度:基底速度(0-2) + 新鲜度(0-2) + 年龄(0-1) + 离去强度(0-3) + 回踩反应(0-2)
"""
_, _, q_score_breakpoints = zone_params()
base_speed_bp = q_score_breakpoints.get("base_speed", [2, 3, 5])
freshness_bp = q_score_breakpoints.get("freshness", [0, 2])
age_bp = q_score_breakpoints.get("age", [20, 40])
leave_ratio_bp = q_score_breakpoints.get("leave_ratio", [1.0, 1.5, 2.5, 3.0])
score = 0.0
# 1. 基底速度静K越少越好说明蓄力时间短、爆发快
if base_speed <= base_speed_bp[0]:
score += 2
elif base_speed <= base_speed_bp[1]:
score += 1.5
elif base_speed <= base_speed_bp[2]:
score += 1
else:
score += 0
# 2. 新鲜度(未被触碰的次数越少越好)
if freshness <= freshness_bp[0]:
score += 2
elif freshness <= freshness_bp[1]:
score += 1
else:
score += 0
# 3. 年龄(越近越好)
if age <= age_bp[0]:
score += 1
elif age <= age_bp[1]:
score += 0.5
else:
score += 0
# 4. 离去强度(离开幅度/区域高度 ≥2.5倍=最强)
if leave_ratio >= leave_ratio_bp[3]:
score += 3
elif leave_ratio >= leave_ratio_bp[2]:
score += 2.5
elif leave_ratio >= leave_ratio_bp[1]:
score += 2
elif leave_ratio >= leave_ratio_bp[0]:
score += 1
else:
score += 0
# 5. 回踩反应(回踩后快速离开=强反应)
score += min(reaction_strength, 2)
return round(score, 1)
def _calc_freshness(df, zone_top, zone_btm, base_index, zone_type):
"""计算区域被触碰次数(新鲜度)"""
touches = 0
price = float(df["close"].iloc[-1])
for j in range(base_index + 1, len(df)):
h = float(df["high"].iloc[j])
l = float(df["low"].iloc[j])
if zone_type == "supply" and l <= zone_top:
touches += 1
elif zone_type == "demand" and h >= zone_btm:
touches += 1
return touches
def _calc_reaction_strength(df, zone_type, zone_top, zone_btm, base_index):
"""计算回踩后反应强度"""
if base_index + 6 >= len(df):
return 0
# 看回踩后2根K线是否快速离开区域
reaction = 0
for j in range(base_index + 2, min(base_index + 8, len(df))):
c = float(df["close"].iloc[j])
if zone_type == "supply":
# 回踩供给区后是否快速远离(向下远离=强反应)
dist = (zone_btm - c) / (zone_top - zone_btm) if (zone_top - zone_btm) > 0 else 0
reaction = max(reaction, max(0, dist))
else:
# 回踩需求区后是否快速远离(向上远离=强反应)
dist = (c - zone_top) / (zone_top - zone_btm) if (zone_top - zone_btm) > 0 else 0
reaction = max(reaction, max(0, dist))
# 映射到0-2分
if reaction >= 2.0:
return 2
elif reaction >= 1.0:
return 1
elif reaction >= 0.5:
return 0.5
return 0
# ==================== 连续K识别趋势加速 ====================
def find_continuous_k(df: pd.DataFrame) -> List[Dict]:
"""
识别连续K线组合高低点不断抬高的阳线群/不断降低的阴线群)
对应TV的is_continue_hl逻辑
返回: [{"type": "bullish_continue"/"bearish_continue", "length": int,
"start_index": int, "strength": float}]
"""
min_count = continuous_k_params().get("min_count", 3)
results = []
if len(df) < min_count:
return results
closes = df["close"].values
opens = df["open"].values
highs = df["high"].values
lows = df["low"].values
# 从最新K线往回找连续模式
i = len(df) - 1
# 多头连续(阳线+高点抬高+低点抬高)
bull_count = 0
bull_start = i
for j in range(i, -1, -1):
if closes[j] > opens[j]: # 阳线
if j < i:
if lows[j] < lows[j+1] and highs[j] < highs[j+1]:
# 低点抬高 + 高点抬高 = 多头连续
bull_count += 1
bull_start = j
else:
break
else:
bull_count = 1
bull_start = j
else:
break
if bull_count >= min_count:
strength = sum(highs[k] - lows[k] for k in range(bull_start, i+1)) / df["close"].iloc[-1]
results.append({
"type": "bullish_continue",
"length": bull_count,
"start_index": bull_start,
"strength": round(float(strength), 4),
})
# 空头连续(阴线+高点降低+低点降低)
bear_count = 0
bear_start = i
for j in range(i, -1, -1):
if closes[j] < opens[j]: # 阴线
if j < i:
if lows[j] > lows[j+1] and highs[j] > highs[j+1]:
bear_count += 1
bear_start = j
else:
break
else:
bear_count = 1
bear_start = j
else:
break
if bear_count >= min_count:
strength = sum(highs[k] - lows[k] for k in range(bear_start, i+1)) / float(df["close"].iloc[-1])
results.append({
"type": "bearish_continue",
"length": bear_count,
"start_index": bear_start,
"strength": round(float(strength), 4),
})
return results
# ==================== 起爆点判定 ====================
def detect_ignition_point(candles_class: List[Dict], df: pd.DataFrame,
atr: float, min_static_count: int = 2) -> List[Dict]:
"""
检测起爆点静K群后出现动K = 从蓄力到爆发的转折
条件:
1. 连续min_static_count根静K蓄力段
2. 紧随其后出现动K方向性爆发
3. 动K实体 > 静K群平均实体 × 3爆发力量显著
返回: [{"index": int, "direction": 1/-1, "strength": float,
"static_before": int, "signal_type": str}]
"""
lookback, cfg_min_static_count, static_search_range, _ = ignition_params()
min_static_count = cfg_min_static_count if cfg_min_static_count else min_static_count
ignitions = []
if not candles_class or atr <= 0:
return ignitions
start_idx = max(0, len(candles_class) - lookback) # 只看最近N根
for i in range(start_idx, len(candles_class)):
c = candles_class[i]
if c["type"] != "dynamic":
continue
# 检查前面是否有静K群
static_count = 0
for j in range(i - 1, max(i - (static_search_range + 1), start_idx - 1), -1):
if candles_class[j]["type"] == "static":
static_count += 1
else:
break
if static_count < min_static_count:
continue
# 检查爆发强度动K实体 vs 静K群平均实体
dynamic_body = abs(float(df["close"].iloc[i]) - float(df["open"].iloc[i]))
# 静K群平均实体
static_bodies = []
for j in range(i - static_count, i):
if candles_class[j]["type"] == "static":
sb = abs(float(df["close"].iloc[j]) - float(df["open"].iloc[j]))
static_bodies.append(sb)
avg_static_body = sum(static_bodies) / len(static_bodies) if static_bodies else 0
# 爆发强度 = 动K实体 / 静K平均实体
if avg_static_body <= 0:
strength_ratio = dynamic_body / atr if atr > 0 else 0
else:
strength_ratio = dynamic_body / avg_static_body
# 动K方向
direction = c["direction"]
# 信号类型命名
if direction == 1:
signal_type = "起爆点↑(静K→阳动K)"
elif direction == -1:
signal_type = "起爆点↓(静K→阴动K)"
else:
continue
age_bars = len(candles_class) - 1 - i
ignitions.append({
"index": i,
"direction": direction,
"strength_ratio": round(strength_ratio, 2),
"static_before": static_count,
"dynamic_atr_ratio": round(c["atr_ratio"], 2),
"signal_type": signal_type,
"age_bars": age_bars,
})
return ignitions
# ==================== 15min入场点分析 ====================
def analyze_entry_point(h1_df: pd.DataFrame, m15_df: pd.DataFrame,
atr_1h: float, zones_4h: List[Dict],
direction: int = 1) -> Dict:
"""
15min级别入场点分析
方向=1(做多):寻找回踩到需求区+出现静K确认 = 最佳买点
方向=-1(做空):寻找反弹到供给区+出现静K确认 = 最佳卖点
返回: {
"action": "即刻买入"/"等回踩"/"放弃",
"entry_type": str,
"wait_price": float, # 等回踩的目标价位
"confidence": float, # 0-1
"reason": str,
"breakout_k_info": dict, # 突破K线信息
"pullback_info": dict, # 回踩信息
"false_breakout": bool, # 是否假突破
}
"""
if m15_df is None or len(m15_df) < 20 or atr_1h <= 0:
return {"action": "放弃", "entry_type": "数据不足",
"wait_price": 0, "confidence": 0, "reason": "15min数据不足",
"false_breakout": False}
entry_cfg = entry_point_params()
recent_15min = entry_cfg.get("recent_15min", 6)
dy_bear_max = entry_cfg.get("dy_bear_max", 3)
price_1h = float(h1_df["close"].iloc[-1]) if h1_df is not None else 0
atr_15 = calc_atr(m15_df, 14)
m15_class = classify_candles(m15_df, atr_15)
# 找最近2-3根15min的突破K线
recent_15 = m15_class[-recent_15min:] # 最近N根15min
breakout_k = None
pullback_k = None
false_breakout = False
# 1. 找突破K线必须是最近/上一根15min内发生避免把1小时前突破当作“正在突破”
max_breakout_age = entry_cfg.get("max_breakout_age_bars", 1)
for c in reversed(recent_15):
age_bars = len(m15_class) - 1 - c.get("index", -1)
if age_bars > max_breakout_age:
continue
if c["type"] == "dynamic" and c["direction"] == direction:
breakout_k = c
breakout_k["age_bars"] = age_bars
break
# 2. 突破后是否有回踩静K或neutral+小实体)
if breakout_k:
bk_idx = breakout_k["index"]
# 看突破K线之后的K线
for c in m15_class[bk_idx+1:]:
if c["type"] == "static" or (c["type"] == "neutral" and c["body_ratio"] < 0.35):
pullback_k = c
break
# 3. 假突破检测突破后立刻回落跌破突破K线低点(做多)/突破K线高点(做空)
if breakout_k:
bk_low = float(m15_df["low"].iloc[breakout_k["index"]])
bk_high = float(m15_df["high"].iloc[breakout_k["index"]])
current_price = float(m15_df["close"].iloc[-1])
if direction == 1 and current_price < bk_low:
false_breakout = True
elif direction == -1 and current_price > bk_high:
false_breakout = True
# 4. 判断入场类型
if false_breakout:
return {
"action": "放弃",
"entry_type": "假突破",
"wait_price": 0,
"confidence": 0,
"reason": "突破后回落,假突破信号",
"breakout_k_info": {"index": breakout_k["index"] if breakout_k else -1,
"atr_ratio": breakout_k["atr_ratio"] if breakout_k else 0},
"pullback_info": {},
"false_breakout": True,
}
# 5. 找目标回踩价位4H需求区或1H均线支撑
wait_price = 0
if direction == 1:
# 做多找最近的4H需求区top作为回踩目标
demand_zones = [z for z in zones_4h if z["type"] == "demand" and z["q_score"] >= 7]
if demand_zones:
# 价格还在需求区上方 → 回踩到需求区top是最佳买点
nearest_demand = min(demand_zones, key=lambda z: abs(z["top"] - price_1h))
if nearest_demand["top"] < price_1h:
wait_price = round(nearest_demand["top"], 6)
else:
# 无高质量需求区用1H MA20作为回踩目标
if h1_df is not None and len(h1_df) >= 20:
ma20_1h = float(h1_df["close"].rolling(20).mean().iloc[-1])
if ma20_1h < price_1h:
wait_price = round(ma20_1h, 6)
elif direction == -1:
# 做空找最近的4H供给区btm作为反弹目标
supply_zones = [z for z in zones_4h if z["type"] == "supply" and z["q_score"] >= 7]
if supply_zones:
nearest_supply = min(supply_zones, key=lambda z: abs(z["btm"] - price_1h))
if nearest_supply["btm"] > price_1h:
wait_price = round(nearest_supply["btm"], 6)
# 6. 综合判断v11纯前瞻版 — 用PA行为替代RSI/布林)
current_price_15 = float(m15_df["close"].iloc[-1])
# 即刻买入条件15min动K连续 + 无连续阴动K超买反转
if breakout_k and not pullback_k:
# 突破正在发生,没有回踩 — PA判断15min近6根中阴动K<3即非超买反转
candles_15 = classify_candles(m15_df, calc_atr(m15_df, 14))
recent_15 = candles_15[-recent_15min:] if len(candles_15) >= recent_15min else candles_15
dy_bear_15 = sum(1 for c in recent_15 if c["type"] == "dynamic" and c["direction"] == -1)
no_overbought_reversal = dy_bear_15 < dy_bear_max
if no_overbought_reversal:
return {
"action": "即刻买入",
"entry_type": "突破进行中(15min动K连续)",
"wait_price": round(current_price_15, 6),
"confidence": 0.8,
"reason": f"15min突破K线正在发生{recent_15min}根阴动K={dy_bear_15}<{dy_bear_max}(无超买反转)",
"breakout_k_info": {"index": breakout_k["index"],
"atr_ratio": round(breakout_k["atr_ratio"], 2),
"direction": breakout_k["direction"],
"age_bars": breakout_k.get("age_bars", 0)},
"pullback_info": {},
"false_breakout": False,
}
# 等回踩条件突破后出现静K回踩
if breakout_k and pullback_k:
pb_idx = pullback_k["index"]
pb_low = float(m15_df["low"].iloc[pb_idx])
pb_high = float(m15_df["high"].iloc[pb_idx])
return {
"action": "等回踩",
"entry_type": "突破后回踩确认",
"wait_price": wait_price if wait_price > 0 else round(pb_low, 6),
"confidence": 0.6,
"reason": f"突破后15min出现静K回踩等价格回到${wait_price or pb_low:.4f}附近入场",
"breakout_k_info": {"index": breakout_k["index"],
"atr_ratio": round(breakout_k["atr_ratio"], 2)},
"pullback_info": {"index": pb_idx,
"low": round(pb_low, 6),
"high": round(pb_high, 6)},
"false_breakout": False,
}
# 放弃条件15min出现连续阴动KPA超买反转信号
candles_15_all = classify_candles(m15_df, calc_atr(m15_df, 14))
recent_15_all = candles_15_all[-recent_15min:] if len(candles_15_all) >= recent_15min else candles_15_all
dy_bear_15_all = sum(1 for c in recent_15_all if c["type"] == "dynamic" and c["direction"] == -1)
if dy_bear_15_all >= dy_bear_max:
return {
"action": "放弃",
"entry_type": "超买反转(PA)",
"wait_price": 0,
"confidence": 0,
"reason": f"15min近{recent_15min}根阴动K={dy_bear_15_all}{dy_bear_max}(超买反转信号)",
"false_breakout": False,
}
# 默认HTF已确认多头15min无明显突破K但也不存在反转信号
# → 不再强制等回踩37%失败率已被数据证实),改为参考当前价即刻入场
# 由上层质量闸门对极端追高做最后拦截
return {
"action": "即刻买入",
"entry_type": "HTF趋势延续(15min无反转)",
"wait_price": round(current_price_15, 6),
"confidence": 0.5,
"reason": f"1H/4H已确认多头15min无动K突破但阴动K={dy_bear_15_all}<{dy_bear_max}(无反转),即刻入场",
"breakout_k_info": {},
"pullback_info": {},
"false_breakout": False,
}
# ==================== 趋势衰减检测 ====================
def detect_trend_exhaustion(h1_df: pd.DataFrame, atr_1h: float) -> Dict:
"""
检测1H级别趋势衰减信号v11纯前瞻版 — 已删除MACD/RSI
衰减信号纯PA行为
- 连续2+根静K方向性力量减弱
- 1H放量阴线×2多头出货
- 1H连续K空头加速趋势加速下行
- 1H连续≥3根阴动K趋势反转
- 放量滞涨(量放大但价格不涨)
返回: {"exhausted": bool, "signals": [...], "severity": "low"/"medium"/"high"}
"""
if h1_df is None or len(h1_df) < 30 or atr_1h <= 0:
return {"exhausted": False, "signals": [], "severity": "low"}
ex_cfg = exhaustion_params()
recent_static_count = ex_cfg.get("recent_static_count", 3)
recent_static_threshold = ex_cfg.get("recent_static_threshold", 2)
high_vol_bear_threshold = ex_cfg.get("high_vol_bear_threshold", 2)
dy_bear_threshold = ex_cfg.get("dy_bear_threshold", 3)
vol_ratio_stagnation = ex_cfg.get("vol_ratio_stagnation", 2.0)
price_change_stagnation_pct = ex_cfg.get("price_change_stagnation", 0.005) * 100
min_count = continuous_k_params().get("min_count", 3)
signals = []
severity = "low"
candles = classify_candles(h1_df, atr_1h)
# 1. 最近2根是否是静K方向性力量减弱
recent_types = [c["type"] for c in candles[-recent_static_count:]]
static_count_recent = sum(1 for t in recent_types if t == "static")
if static_count_recent >= recent_static_threshold:
signals.append(f"1H连续{static_count_recent}静K(方向性减弱)")
severity = "medium"
# 2. 1H放量阴线×2多头出货 — 替代MACD柱收缩
vol_avg = float(h1_df["volume"].rolling(20).mean().iloc[-1])
recent_3 = h1_df.tail(3)
high_vol_bear = 0
for _, row in recent_3.iterrows():
vol_r = row["volume"] / vol_avg if vol_avg > 0 else 0
if vol_r >= vol_ratio_stagnation and row["close"] < row["open"]:
high_vol_bear += 1
if high_vol_bear >= high_vol_bear_threshold:
signals.append(f"1H放量阴线×{high_vol_bear}(多头出货)")
severity = "medium" if severity == "low" else severity
# 3. 1H连续K空头加速 — 替代RSI回落
cont_k = find_continuous_k(h1_df)
for ck in cont_k:
if ck["type"] == "bearish_continue" and ck["length"] >= min_count:
signals.append(f"1H连续{ck['length']}K空头加速")
severity = "medium" if severity == "low" else severity
# 4. 1H连续≥3根阴动K趋势反转 — 替代MACD死叉
recent_candles = candles[-6:] if len(candles) >= 6 else candles
dy_bear_count = sum(1 for c in recent_candles if c["type"] == "dynamic" and c["direction"] == -1)
if dy_bear_count >= dy_bear_threshold:
signals.append(f"1H连续{dy_bear_count}根阴动K(趋势反转)")
severity = "high"
# 5. 放量滞涨PA行为
vol_latest = float(h1_df["volume"].iloc[-1])
vol_ratio = vol_latest / vol_avg if vol_avg > 0 else 1
price_change_latest = (float(h1_df["close"].iloc[-1]) / float(h1_df["close"].iloc[-2]) - 1) * 100
if vol_ratio >= vol_ratio_stagnation and abs(price_change_latest) < price_change_stagnation_pct:
signals.append(f"1H放量滞涨(量{vol_ratio:.1f}倍但涨{price_change_latest:.2f}%)")
severity = "high"
exhausted = severity != "low"
return {
"exhausted": exhausted,
"signals": signals,
"severity": severity,
}
# ==================== 综合PA分析 ====================
def full_pa_analysis(df: pd.DataFrame, timeframe: str = "4h") -> Dict:
"""
对单币单时间框架做完整PA分析
返回: {
"candles_class": [...],
"zones": [...],
"continuous_k": [...],
"ignition_points": [...],
"atr": float,
"trend_exhaustion": {...},
}
"""
atr = calc_atr(df, 14)
if atr <= 0 or len(df) < 30:
return {"candles_class": [], "zones": [], "continuous_k": [],
"ignition_points": [], "atr": 0, "trend_exhaustion": {"exhausted": False, "signals": [], "severity": "low"}}
candles_class = classify_candles(df, atr)
zones = find_supply_demand_zones(df, candles_class, atr)
continuous_k = find_continuous_k(df)
ignition_points = detect_ignition_point(candles_class, df, atr)
# 只在1H级别检测趋势衰减
exhaustion = {}
if timeframe == "1h":
exhaustion = detect_trend_exhaustion(df, atr)
else:
exhaustion = {"exhausted": False, "signals": [], "severity": "low"}
return {
"candles_class": candles_class,
"zones": zones,
"continuous_k": continuous_k,
"ignition_points": ignition_points,
"atr": round(atr, 6),
"trend_exhaustion": exhaustion,
}