astock-agent/backend/app/analysis/breakout_signals.py
2026-04-30 10:05:41 +08:00

808 lines
24 KiB
Python
Raw Permalink 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.

"""入场信号分类 + 供需分析 + 量价形态识别
五种入场信号类型:
- 突破型 (breakout): 放量突破 20 日阻力位
- 确认型 (breakout_confirm): 突破后 1-2 日放量确认
- 回踩型 (pullback): 上升趋势中缩量回踩均线支撑
- 启动型 (launch): 缩量整理后首日放量
- 反转型 (reversal): 连续下跌后放量长阳反转
"""
import logging
from enum import Enum
import numpy as np
import pandas as pd
from app.config import settings
logger = logging.getLogger(__name__)
class EntrySignal(str, Enum):
BREAKOUT = "breakout"
BREAKOUT_CONFIRM = "breakout_confirm"
PULLBACK = "pullback"
LAUNCH = "launch"
REVERSAL = "reversal"
NONE = "none"
# ── 入场信号检测 ──
def classify_entry_signal(df: pd.DataFrame) -> dict:
"""分类入场信号类型
优先级: breakout > breakout_confirm > pullback > launch > reversal
Returns:
{
"signal_type": EntrySignal,
"signal_score": float (0-100),
"details": dict,
}
"""
if df is None or len(df) < 30:
return {"signal_type": EntrySignal.NONE, "signal_score": 0, "details": {}}
# 突破型
result = detect_breakout(df)
if result:
result["signal_score"] = _score_breakout(df, result)
return result
# 突破确认型(突破后 1-3 日放量确认)
result = detect_breakout_confirm(df)
if result:
result["signal_score"] = _score_breakout_confirm(df, result)
return result
# 回踩型
result = detect_pullback(df)
if result:
result["signal_score"] = _score_pullback(df, result)
return result
# 启动型(缩量横盘后首日放量)
result = detect_launch(df)
if result:
result["signal_score"] = _score_launch(df, result)
return result
# 反转型(连续下跌后放量长阳)
result = detect_reversal(df)
if result:
result["signal_score"] = _score_reversal(df, result)
return result
return {"signal_type": EntrySignal.NONE, "signal_score": 0, "details": {}}
def detect_breakout(df: pd.DataFrame) -> dict | None:
"""突破型检测(严格版:必须真正突破 + 显著放量)
条件:
1. 收盘价真正突破 20 日高点close > resist_20d不再允许"贴近"
2. 量能显著放大 (vol > vol_ma5 * 1.5)
3. MA5 > MA20短期偏多
4. 收盘偏强 (close 在当日上半部)
"""
last = df.iloc[-1]
required = ["close", "high", "low", "vol", "ma5", "ma10", "ma20", "vol_ma5"]
if any(c not in df.columns for c in required):
return None
if pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0:
return None
if len(df) < 22:
return None
resist_20d = df["high"].iloc[-21:-1].max()
# 严格:必须真正突破(收盘价 > 20日高点不允许"贴近未突破"
if last["close"] <= resist_20d:
return None
# 量能显著放大(从 1.2 提高到 1.5
if last["vol"] <= last["vol_ma5"] * 1.5:
return None
# MA5 > MA20
if pd.isna(last["ma20"]) or not (last["ma5"] > last["ma20"]):
return None
# 收盘偏强
day_range = last["high"] - last["low"]
if day_range > 0 and (last["close"] - last["low"]) / day_range < 0.45:
return None
breakout_pct = (last["close"] - resist_20d) / resist_20d * 100
return {
"signal_type": EntrySignal.BREAKOUT,
"details": {
"resist_level": round(resist_20d, 2),
"resistance_price": round(resist_20d, 2), # 阻力位价格,供止损计算用
"breakout_pct": round(breakout_pct, 2),
"volume_ratio": round(last["vol"] / last["vol_ma5"], 2),
},
}
def detect_breakout_confirm(df: pd.DataFrame) -> dict | None:
"""突破确认型检测
条件:近 1-3 日内曾突破 20 日高点,今日放量确认(不要求再创新高)
这捕捉了"突破→缩量回踩确认→再放量"的经典模式。
1. 近 3 日内有 1 日 close > 20日前的高点
2. 今日 vol > vol_ma5放量确认
3. 今日 close > 昨日 close * 0.99(没有大幅回落)
4. MA5 > MA20
"""
if len(df) < 24:
return None
last = df.iloc[-1]
required = ["close", "high", "low", "vol", "pct_chg", "ma5", "ma20", "vol_ma5"]
if any(c not in df.columns for c in required):
return None
if pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0:
return None
if pd.isna(last["ma20"]):
return None
# 20 日阻力(不含近 3 日)
resist_20d = df["high"].iloc[-23:-3].max()
# 近 3 日内有突破
recent_3 = df.tail(3)
had_breakout = any(
row["close"] > resist_20d
for _, row in recent_3.iterrows()
if not pd.isna(row["close"])
)
if not had_breakout:
return None
# 今日放量
if last["vol"] <= last["vol_ma5"] * 1.0:
return None
# 今日没有大幅回落
if last["close"] < df.iloc[-2]["close"] * 0.99:
return None
# MA5 > MA20
if not (last["ma5"] > last["ma20"]):
return None
return {
"signal_type": EntrySignal.BREAKOUT_CONFIRM,
"details": {
"resist_level": round(resist_20d, 2),
"volume_ratio": round(last["vol"] / last["vol_ma5"], 2),
"confirm_pct": round(last["pct_chg"], 2),
},
}
def detect_pullback(df: pd.DataFrame) -> dict | None:
"""回踩型检测
条件:
1. MA20 > MA60 — 中长期上升趋势
2. 近 5 日内有 MA5 > MA20 — 近期处于上升趋势
3. 价格靠近 MA10 或 MA20 (<3%)
4. 近 3 日缩量(相对前 5 日)或 当日开始放量反弹
5. RSI 只做风险提示,不作为回踩成立的硬条件
"""
last = df.iloc[-1]
required = ["close", "vol", "ma5", "ma10", "ma20", "ma60"]
if any(c not in df.columns for c in required):
return None
if pd.isna(last["ma60"]):
return None
# 中长期上升趋势
if not (last["ma20"] > last["ma60"]):
return None
# 近 5 日内有 MA5 > MA20
recent_5 = df.tail(5)
has_uptrend = any(
row["ma5"] > row["ma20"]
for _, row in recent_5.iterrows()
if not pd.isna(row["ma5"]) and not pd.isna(row["ma20"])
)
if not has_uptrend:
return None
# 价格靠近 MA10 或 MA20放宽至 3%
near_ma10 = (
not pd.isna(last["ma10"])
and last["ma10"] > 0
and abs(last["close"] - last["ma10"]) / last["ma10"] < 0.03
)
near_ma20 = (
not pd.isna(last["ma20"])
and last["ma20"] > 0
and abs(last["close"] - last["ma20"]) / last["ma20"] < 0.03
)
if not (near_ma10 or near_ma20):
return None
# 缩量回调 OR 当日放量反弹(二选一)
if len(df) < 8:
return None
recent_3_vol = df["vol"].tail(3).mean()
prev_5_vol = df["vol"].iloc[-8:-3].mean()
shrinking = prev_5_vol > 0 and recent_3_vol / prev_5_vol < 0.9
bouncing_today = (
last["pct_chg"] > 0
and not pd.isna(last["vol_ma5"])
and last["vol_ma5"] > 0
and last["vol"] > last["vol_ma5"] * 1.1
)
if not (shrinking or bouncing_today):
return None
support_ma = "MA10" if near_ma10 else "MA20"
support_price = last["ma10"] if near_ma10 else last["ma20"]
shrink_ratio = recent_3_vol / prev_5_vol if prev_5_vol > 0 else 1
rsi14 = last.get("rsi14") if "rsi14" in df.columns else None
return {
"signal_type": EntrySignal.PULLBACK,
"details": {
"support_ma": support_ma,
"support_price": round(support_price, 2),
"volume_shrink_ratio": round(shrink_ratio, 2),
"bouncing": bouncing_today,
"rsi14": round(float(rsi14), 1) if rsi14 is not None and not pd.isna(rsi14) else None,
},
}
def detect_launch(df: pd.DataFrame) -> dict | None:
"""启动型检测(缩量横盘后首日放量)
条件:
1. 近 5-10 日缩量至均量的 60% 以下
2. 今日放量vol > vol_ma5 * 1.2
3. close > MA60中长线偏多
4. 今日涨跌幅 > 0方向向上
"""
last = df.iloc[-1]
required = ["close", "high", "vol", "pct_chg", "ma5", "ma20", "ma60", "vol_ma5"]
if any(c not in df.columns for c in required):
return None
if pd.isna(last["ma60"]) or pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0:
return None
# close > MA60
if last["close"] < last["ma60"]:
return None
# 今日放量
vol_ratio_today = last["vol"] / last["vol_ma5"]
if vol_ratio_today < 1.2:
return None
# 今日上涨
if last["pct_chg"] <= 0:
return None
# 近 5-10 日有缩量期(存在 vol < vol_ma5 * 0.6 的日子)
if len(df) < 12:
return None
recent_10 = df.iloc[-11:-1] # 不含今日
vol_ma_col = "vol_ma10" if "vol_ma10" in df.columns else "vol_ma5"
had_dry_up = False
for _, row in recent_10.iterrows():
if pd.isna(row.get(vol_ma_col)) or row[vol_ma_col] == 0:
continue
if row["vol"] < row[vol_ma_col] * 0.6:
had_dry_up = True
break
if not had_dry_up:
return None
# 近 10 日振幅不大(横盘整理)
recent_10_close = df["close"].iloc[-11:-1]
if len(recent_10_close) < 5:
return None
price_range = (recent_10_close.max() - recent_10_close.min()) / recent_10_close.min()
if price_range > 0.12: # 12% 以内算横盘
return None
high_20d = df["high"].tail(20).max()
return {
"signal_type": EntrySignal.LAUNCH,
"details": {
"volume_ratio_today": round(vol_ratio_today, 2),
"price_range_pct": round(price_range * 100, 2),
"resist_level": round(high_20d, 2),
},
}
def detect_reversal(df: pd.DataFrame) -> dict | None:
"""反转型检测(连续下跌后放量长阳)
条件:
1. 近 5 日有 3 日以上下跌
2. 今日放量长阳pct_chg > 3%, vol > vol_ma5 * 1.5
3. 收盘价高于近 5 日最高价(强势反转)
4. RSI 低位只做背景信息,不作为反转成立的硬条件
"""
if len(df) < 10:
return None
last = df.iloc[-1]
required = ["close", "high", "low", "vol", "pct_chg", "vol_ma5"]
if any(c not in df.columns for c in required):
return None
if pd.isna(last["vol_ma5"]) or last["vol_ma5"] == 0:
return None
# 近 5 日有 3 日以上下跌
recent_5 = df.tail(5)
down_count = sum(1 for _, r in recent_5.iterrows() if r["pct_chg"] < 0)
if down_count < 3:
return None
# 今日放量长阳
if last["pct_chg"] < 3:
return None
if last["vol"] < last["vol_ma5"] * 1.5:
return None
# 收盘价高于近 5 日最高价(强势反转)
high_5d = df["high"].iloc[-6:-1].max()
if last["close"] < high_5d:
return None
min_rsi = None
if "rsi14" in df.columns:
rsi_5d = df["rsi14"].tail(5)
min_rsi = rsi_5d.min()
return {
"signal_type": EntrySignal.REVERSAL,
"details": {
"down_days": down_count,
"reversal_pct": round(last["pct_chg"], 2),
"volume_ratio": round(last["vol"] / last["vol_ma5"], 2),
"min_rsi": round(float(min_rsi), 1) if min_rsi is not None and not pd.isna(min_rsi) else None,
},
}
# ── 信号质量评分 ──
def _score_breakout(df: pd.DataFrame, signal: dict) -> float:
"""突破型评分 (0-100)"""
last = df.iloc[-1]
score = 0
breakout_pct = signal["details"].get("breakout_pct", 0)
if breakout_pct > 3:
score += 25
elif breakout_pct > 1.5:
score += 18
elif breakout_pct > 0:
score += 12
# 不再有"贴近未突破"分支,只有真正突破才到这里
vol_ratio = signal["details"].get("volume_ratio", 1)
if vol_ratio > 2.5:
score += 25
elif vol_ratio > 2.0:
score += 20
elif vol_ratio > 1.5:
score += 14
elif vol_ratio > 1.2:
score += 8
if all(c in df.columns for c in ["ma60"]):
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
score += 25
elif last["ma5"] > last["ma10"] > last["ma20"]:
score += 15
else:
score += 10
if all(c in df.columns for c in ["dif", "dea"]):
if not pd.isna(last["dif"]) and last["dif"] > last["dea"] and last["dif"] > 0:
score += 15
elif not pd.isna(last["dif"]) and last["dif"] > last["dea"]:
score += 8
if "rsi14" in df.columns and not pd.isna(last["rsi14"]):
if 50 <= last["rsi14"] <= 75:
score += 4
elif 45 <= last["rsi14"] <= 80:
score += 2
return min(score, 100)
def _score_breakout_confirm(df: pd.DataFrame, signal: dict) -> float:
"""突破确认型评分 (0-100)"""
last = df.iloc[-1]
score = 0
vol_ratio = signal["details"].get("volume_ratio", 1)
if vol_ratio > 2.0:
score += 25
elif vol_ratio > 1.5:
score += 18
elif vol_ratio > 1.0:
score += 10
confirm_pct = signal["details"].get("confirm_pct", 0)
if confirm_pct > 3:
score += 20
elif confirm_pct > 1:
score += 14
elif confirm_pct > 0:
score += 8
if all(c in df.columns for c in ["ma60"]):
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
score += 25
elif last["ma5"] > last["ma10"] > last["ma20"]:
score += 15
if all(c in df.columns for c in ["dif", "dea"]):
if not pd.isna(last["dif"]) and last["dif"] > last["dea"] and last["dif"] > 0:
score += 15
elif not pd.isna(last["dif"]) and last["dif"] > last["dea"]:
score += 8
if "rsi14" in df.columns and not pd.isna(last["rsi14"]):
if 50 <= last["rsi14"] <= 70:
score += 4
elif 45 <= last["rsi14"] <= 75:
score += 2
return min(score, 100)
def _score_pullback(df: pd.DataFrame, signal: dict) -> float:
"""回踩型评分 (0-100)"""
last = df.iloc[-1]
score = 0
shrink = signal["details"].get("volume_shrink_ratio", 1)
bouncing = signal["details"].get("bouncing", False)
if bouncing:
score += 20 # 已开始反弹加分
elif shrink < 0.5:
score += 30
elif shrink < 0.6:
score += 22
elif shrink < 0.7:
score += 15
elif shrink < 0.9:
score += 8
support_ma = signal["details"].get("support_ma", "")
if support_ma == "MA20":
score += 18
elif support_ma == "MA10":
score += 12
if all(c in df.columns for c in ["ma60"]):
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma10"] > last["ma20"] > last["ma60"]:
score += 25
elif last["ma5"] > last["ma10"] > last["ma20"]:
score += 15
if all(c in df.columns for c in ["dif", "dea"]):
if not pd.isna(last["dif"]) and last["dif"] > 0:
score += 10
elif not pd.isna(last["dif"]) and last["dif"] > last["dea"]:
score += 5
if "rsi14" in df.columns and not pd.isna(last["rsi14"]):
if 40 <= last["rsi14"] <= 55:
score += 3
elif 35 <= last["rsi14"] <= 65:
score += 1
return min(score, 100)
def _score_launch(df: pd.DataFrame, signal: dict) -> float:
"""启动型评分 (0-100)"""
last = df.iloc[-1]
score = 0
details = signal.get("details", {})
range_pct = details.get("price_range_pct", 10)
if range_pct < 4:
score += 20
elif range_pct < 8:
score += 14
elif range_pct < 12:
score += 8
vol_ratio = details.get("volume_ratio_today", 1)
if vol_ratio > 2.5:
score += 25
elif vol_ratio > 1.8:
score += 18
elif vol_ratio > 1.2:
score += 10
if all(c in df.columns for c in ["ma60"]):
if not pd.isna(last["ma60"]) and last["ma5"] > last["ma20"] > last["ma60"]:
score += 20
elif last["ma5"] > last["ma20"]:
score += 10
if all(c in df.columns for c in ["dif", "dea"]):
if not pd.isna(last["dif"]) and last["dif"] > 0:
score += 15
elif not pd.isna(last["dif"]):
score += 5
# MACD 金叉或 DIF 上翘加分
if "dif" in df.columns and "dea" in df.columns and len(df) >= 3:
if not pd.isna(last["dif"]) and not pd.isna(df.iloc[-3]["dif"]):
if last["dif"] > df.iloc[-3]["dif"]: # DIF 上翘
score += 10
return min(score, 100)
def _score_reversal(df: pd.DataFrame, signal: dict) -> float:
"""反转型评分 (0-100)"""
last = df.iloc[-1]
score = 0
reversal_pct = signal["details"].get("reversal_pct", 0)
if reversal_pct > 5:
score += 25
elif reversal_pct > 3:
score += 18
vol_ratio = signal["details"].get("volume_ratio", 1)
if vol_ratio > 3:
score += 25
elif vol_ratio > 2:
score += 18
elif vol_ratio > 1.5:
score += 12
# 站上 MA5 加分
if "ma5" in df.columns and not pd.isna(last["ma5"]):
if last["close"] > last["ma5"]:
score += 10
# 量能超过近 20 日最大量
if len(df) >= 20:
vol_20d_max = df["vol"].iloc[-21:-1].max()
if last["vol"] > vol_20d_max:
score += 10
return min(score, 100)
# ── 供需分析评分 ──
def score_supply_demand(df: pd.DataFrame) -> float:
"""供需评分 (0-100),窗口从 10 日扩展到 20 日
维度:
- 上涨日 vs 下跌日量比 (40): 需求主导度
- 量能收缩/扩张 (25): 供需变化趋势
- 量价配合 (20): 上涨放量+下跌缩量
- 经典量价形态 (15): 缩量横盘→放量、底部量堆
"""
score = 0
if len(df) < 10:
return 50.0
# 使用 20 日窗口(如果数据够)
window = min(20, len(df))
recent = df.tail(window)
last = df.iloc[-1]
# 上涨日 vs 下跌日量比 (40)
up_days = recent[recent["pct_chg"] > 0]
down_days = recent[recent["pct_chg"] <= 0]
if len(up_days) > 0 and len(down_days) > 0:
avg_up_vol = up_days["vol"].mean()
avg_down_vol = down_days["vol"].mean()
if avg_down_vol > 0:
ratio = avg_up_vol / avg_down_vol
if ratio > 2.0:
score += 40
elif ratio > 1.5:
score += 30
elif ratio > 1.2:
score += 20
elif ratio > 1.0:
score += 10
elif len(up_days) > len(down_days):
score += 30
# 量能收缩/扩张 (25)
vol_ma_col = "vol_ma10" if "vol_ma10" in df.columns else "vol_ma5"
if vol_ma_col in df.columns and not pd.isna(last[vol_ma_col]) and last[vol_ma_col] > 0:
recent_3_vol = recent["vol"].tail(3).mean()
shrink_ratio = recent_3_vol / last[vol_ma_col]
if shrink_ratio < 0.4:
score += 25
elif shrink_ratio < 0.6:
score += 18
elif shrink_ratio < 0.8:
score += 10
elif shrink_ratio > 1.5:
score += 20
elif shrink_ratio > 1.2:
score += 12
# 量价配合 (20)
if _check_bullish_volume_divergence(df):
score += 20
# 经典量价形态 (15)
if _detect_volume_pattern(df) > 0:
score += 15
return min(score, 100)
def _detect_volume_pattern(df: pd.DataFrame) -> int:
"""检测经典量价形态
返回形态强度 (0-2):
1 = 缩量横盘后首日放量
2 = 底部量堆(近 20 日出现量堆+价格企稳)
"""
if len(df) < 20:
return 0
strength = 0
# 形态 1缩量横盘后首日放量
vol_ma_col = "vol_ma10" if "vol_ma10" in df.columns else "vol_ma5"
if vol_ma_col in df.columns:
recent_10 = df.iloc[-11:-1]
had_dry_up = any(
row["vol"] < row[vol_ma_col] * 0.5
for _, row in recent_10.iterrows()
if not pd.isna(row[vol_ma_col]) and row[vol_ma_col] > 0
)
last = df.iloc[-1]
vol_expanding = (
not pd.isna(last[vol_ma_col])
and last[vol_ma_col] > 0
and last["vol"] > last[vol_ma_col] * 1.3
and last["pct_chg"] > 0
)
if had_dry_up and vol_expanding:
strength = max(strength, 1)
# 形态 2底部量堆
if len(df) >= 20:
vol_20d = df["vol"].tail(20)
vol_mean = vol_20d.mean()
if vol_mean > 0:
vol_max = vol_20d.max()
vol_min = vol_20d.min()
# 量堆:存在量能是均量 2 倍以上的日子,且价格没有大幅下跌
high_vol_days = sum(1 for v in vol_20d if v > vol_mean * 2)
price_change = (df["close"].iloc[-1] - df["close"].iloc[-20]) / df["close"].iloc[-20] * 100
if high_vol_days >= 2 and price_change > -3:
strength = max(strength, 2)
return strength
def analyze_volume_pattern(df: pd.DataFrame) -> dict:
"""量价模式分析"""
default = {"demand_supply_ratio": 1.0, "volume_trend": "stable", "accumulation_score": 0}
if len(df) < 10:
return default
window = min(20, len(df))
recent = df.tail(window)
up_days = recent[recent["pct_chg"] > 0]
down_days = recent[recent["pct_chg"] <= 0]
avg_up_vol = up_days["vol"].mean() if len(up_days) > 0 else 0
avg_down_vol = down_days["vol"].mean() if len(down_days) > 0 else 1
ratio = round(avg_up_vol / max(avg_down_vol, 1), 2)
if len(df) >= 10:
half = window // 2
vol_first = df["vol"].iloc[-window:-half].mean()
vol_recent = df["vol"].tail(half).mean()
if vol_first > 0:
trend_ratio = vol_recent / vol_first
trend = "expanding" if trend_ratio > 1.2 else ("contracting" if trend_ratio < 0.8 else "stable")
else:
trend = "stable"
else:
trend = "stable"
accumulation = 0
if ratio > 1.5:
accumulation += 40
if trend == "expanding" and df.iloc[-1]["pct_chg"] > 0:
accumulation += 30
if _check_volume_dry_up_before_rally(df):
accumulation += 30
return {
"demand_supply_ratio": ratio,
"volume_trend": trend,
"accumulation_score": accumulation,
}
# ── 辅助函数 ──
def _check_higher_highs(df: pd.DataFrame) -> bool:
if len(df) < 20:
return False
recent = df.tail(20)
highs = recent["high"].values
first_half_max = highs[:10].max()
second_half_max = highs[10:].max()
return second_half_max >= first_half_max
def _check_bullish_volume_divergence(df: pd.DataFrame) -> bool:
if len(df) < 6:
return False
recent = df.tail(5)
up_days = recent[recent["pct_chg"] > 0]
down_days = recent[recent["pct_chg"] <= 0]
if len(up_days) == 0 or len(down_days) == 0:
return len(up_days) > len(down_days)
avg_up_vol = up_days["vol"].mean()
avg_down_vol = down_days["vol"].mean()
return avg_up_vol > avg_down_vol
def _check_volume_dry_up_before_rally(df: pd.DataFrame) -> bool:
if len(df) < 15:
return False
last = df.iloc[-1]
vol_ma_col = "vol_ma10" if "vol_ma10" in df.columns else "vol_ma5"
if vol_ma_col not in df.columns:
return False
recent_10 = df.tail(10)
had_dry_up = any(
row["vol"] < row[vol_ma_col] * 0.5
for _, row in recent_10.iterrows()
if not pd.isna(row[vol_ma_col]) and row[vol_ma_col] > 0
)
vol_ma_col_5 = "vol_ma5" if "vol_ma5" in df.columns else vol_ma_col
vol_expanding = (
not pd.isna(last[vol_ma_col_5])
and last[vol_ma_col_5] > 0
and last["vol"] > last[vol_ma_col_5] * 1.2
)
return had_dry_up and vol_expanding