增加均量突破回踩与真实涨停整理条件

This commit is contained in:
boris
2026-09-09 13:55:45 +08:00
parent 30e8227099
commit fe8f6c1c26
+113 -12
View File
@@ -13,6 +13,10 @@ pub fn catalog() -> Value {
"strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"}, "strength":{"label":"趋势强势","parameters":{"momentum_window":[25,5,120],"fast_window":[20,2,60],"slow_window":[60,20,252]},"stages":["selection","buy"],"method":"收盘价>短均线>长均线,按区间动量排序;不是当日金叉。"},
"breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"}, "breakout":{"label":"前高突破","parameters":{"high_window":[60,5,252],"volume_window":[10,2,60],"volume_multiple":[1.3,1,10],"max_upper_shadow":[0.1,0,1]},"stages":["selection","buy"],"method":"收盘突破此前N日最高价,量达到此前M日均量倍数,上影比例受限;参考窗口不含当日。"},
"volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"}, "volume_spike":{"label":"放量上涨","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"当日上涨且量达到此前N日最大量的指定倍数;不等同价格创新高。"},
"mean_volume_spike":{"label":"均量倍增","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["selection","buy"],"method":"量达到此前N个交易日均量的M倍且当日上涨。分母不含当日;保留与最大量规则的区别。"},
"mean_shrink_breakout":{"label":"倍量后缩量阳线突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前出现N日均量M倍放量,当前缩量阳线收盘突破该放量日最高价。"},
"breakout_retest":{"label":"突破回踩站回","parameters":{"high_window":[60,5,252],"retest_lookback":[10,2,30],"price_tolerance":[0.02,0,0.2],"shrink_ratio":[0.8,0.01,1]},"stages":["selection","buy"],"method":"观察窗先收盘突破此前N日最高价,随后低点回踩突破位容差区,今日收盘站回该位且不低于昨日、成交量收缩。突破与回踩不得同日。"},
"limit_consolidation":{"label":"涨停后整理(日线)","parameters":{"anchor_lag":[4,2,30],"price_band":[0.05,0,0.3],"volume_band":[0.15,0,2],"ma_window":[5,2,60]},"stages":["selection","buy"],"method":"明确T-i日按真实涨停价收盘,后续收盘和量相对锚日偏离受限,今日收盘低于完整日线均线;不是盘中动态MA条件。"},
"shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"}, "shrink_breakout":{"label":"缩量突破","parameters":{"spike_lookback":[5,2,30],"volume_window":[5,2,60],"volume_multiple":[3.0,1,10],"shrink_ratio":[0.5,0.01,1]},"stages":["selection","buy"],"method":"此前观察窗有放量日,今日收盘超过该日最高价,成交量不超过其指定比例。"},
"ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"}, "ma_below":{"label":"均线下方","parameters":{"ma_window":[20,2,252]},"stages":["sell"],"method":"完整收盘价低于含当日的N日均线;独立卖出条件。"},
"volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"} "volume_down":{"label":"放量下跌","parameters":{"volume_window":[5,2,60],"volume_multiple":[3.0,1,10]},"stages":["sell"],"method":"当日下跌且量达到此前N日最大量的指定倍数。"}
@@ -75,7 +79,7 @@ impl PatternSpec {
if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() { if number < bounds[1].as_f64().unwrap() || number > bounds[2].as_f64().unwrap() {
return Err(format!("{key}超出允许范围")); return Err(format!("{key}超出允许范围"));
} }
if key.ends_with("window") || key == "spike_lookback" { if key.ends_with("window") || key.ends_with("lookback") || key == "anchor_lag" {
if number.fract() != 0.0 { if number.fract() != 0.0 {
return Err(format!("{key}必须是整数")); return Err(format!("{key}必须是整数"));
} }
@@ -100,9 +104,11 @@ impl PatternSpec {
"expression" => self.n("history_window"), "expression" => self.n("history_window"),
"strength" => self.n("slow_window").max(self.n("momentum_window") + 1), "strength" => self.n("slow_window").max(self.n("momentum_window") + 1),
"breakout" => self.n("high_window").max(self.n("volume_window")) + 1, "breakout" => self.n("high_window").max(self.n("volume_window")) + 1,
"volume_spike" | "volume_down" => self.n("volume_window") + 1, "volume_spike" | "volume_down" | "mean_volume_spike" => self.n("volume_window") + 1,
"breakout_retest" => self.n("high_window") + self.n("retest_lookback") + 1,
"limit_consolidation" => (self.n("anchor_lag")+1).max(self.n("ma_window")),
"ma_below" => self.n("ma_window").max(2), "ma_below" => self.n("ma_window").max(2),
"shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1, "shrink_breakout" | "mean_shrink_breakout" => self.n("spike_lookback") + self.n("volume_window") + 1,
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -121,6 +127,10 @@ pub struct PatternBar {
pub prev_close: Option<f64>, pub prev_close: Option<f64>,
#[serde(default)] #[serde(default)]
pub amount: Option<f64>, pub amount: Option<f64>,
#[serde(default)]
pub upper_limit: Option<f64>,
#[serde(default)]
pub no_limit: Option<bool>,
pub adjustment_factor_backward1: Option<f64>, pub adjustment_factor_backward1: Option<f64>,
pub paused: Option<bool>, pub paused: Option<bool>,
#[serde(default)] #[serde(default)]
@@ -434,11 +444,12 @@ pub fn evaluate(
spec.v("max_upper_shadow"), spec.v("max_upper_shadow"),
); );
} }
"volume_spike" | "volume_down" => { "volume_spike" | "volume_down" | "mean_volume_spike" => {
let high = prices[len - 1 - spec.n("volume_window")..len - 1] let reference = &prices[len - 1 - spec.n("volume_window")..len - 1];
let high = if spec.template=="mean_volume_spike" {mean(reference.iter().map(|b|b.4))?} else {reference
.iter() .iter()
.map(|b| b.4) .map(|b| b.4)
.fold(0.0, f64::max); .fold(0.0, f64::max)};
if high <= 0.0 { if high <= 0.0 {
return Err(format!( return Err(format!(
"pattern_input_invalid: symbol={}, reason=zero_reference_volume", "pattern_input_invalid: symbol={}, reason=zero_reference_volume",
@@ -449,20 +460,20 @@ pub fn evaluate(
result.values["volume_ratio"] = json!(v / high); result.values["volume_ratio"] = json!(v / high);
check( check(
&mut result.checks, &mut result.checks,
"最大量倍数", if spec.template=="mean_volume_spike" {"均量倍数"} else {"最大量倍数"},
v / high, v / high,
">=", ">=",
spec.v("volume_multiple"), spec.v("volume_multiple"),
); );
check( check(
&mut result.checks, &mut result.checks,
if spec.template == "volume_spike" { if spec.template != "volume_down" {
"当日上涨" "当日上涨"
} else { } else {
"当日下跌" "当日下跌"
}, },
change, change,
if spec.template == "volume_spike" { if spec.template != "volume_down" {
">" ">"
} else { } else {
"<" "<"
@@ -476,14 +487,15 @@ pub fn evaluate(
result.values["ma"] = json!(avg); result.values["ma"] = json!(avg);
check(&mut result.checks, "收盘低于均线", c, "<", avg); check(&mut result.checks, "收盘低于均线", c, "<", avg);
} }
"shrink_breakout" => { "shrink_breakout" | "mean_shrink_breakout" => {
let mut spikes = Vec::new(); let mut spikes = Vec::new();
let mut eligible = Vec::new(); let mut eligible = Vec::new();
for i in len - 1 - spec.n("spike_lookback")..len - 1 { for i in len - 1 - spec.n("spike_lookback")..len - 1 {
let prior = prices[i - spec.n("volume_window")..i] let reference=&prices[i - spec.n("volume_window")..i];
let prior = if spec.template=="mean_shrink_breakout"{mean(reference.iter().map(|b|b.4))?}else{reference
.iter() .iter()
.map(|b| b.4) .map(|b| b.4)
.fold(0.0, f64::max); .fold(0.0, f64::max)};
if prior <= 0.0 { if prior <= 0.0 {
return Err(format!( return Err(format!(
"pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume", "pattern_input_invalid: symbol={}, date={}, reason=zero_reference_volume",
@@ -523,6 +535,44 @@ pub fn evaluate(
spec.v("shrink_ratio"), spec.v("shrink_ratio"),
); );
} }
if spec.template=="mean_shrink_breakout" {check(&mut result.checks,"当前为阳线",c,">",o);}
}
"breakout_retest" => {
let mut anchors=Vec::new();let mut eligible=Vec::new();
for i in len-1-spec.n("retest_lookback")..len-1 {
let level=prices[i-spec.n("high_window")..i].iter().map(|b|b.1).fold(f64::NEG_INFINITY,f64::max);
if prices[i].3<=level {continue;}
let retraced=prices[i+1..].iter().any(|b| b.2 <= level*(1.0+spec.v("price_tolerance")));
anchors.push((i,level,retraced));
if retraced&&c>=level&&c>=prices[len-2].3&&prices[i].4>0.0&&v<=prices[i].4*spec.v("shrink_ratio") {eligible.push((i,level,retraced));}
}
check(&mut result.checks,"观察窗存在先前突破",anchors.len() as f64,">",0.0);
if let Some(&(i,level,retraced))=eligible.last().or_else(||anchors.last()) {
result.values["breakout_date"]=json!(days[i]);result.values["breakout_level"]=json!(level);result.values["days_since_breakout"]=json!(len-1-i);
check(&mut result.checks,"突破后曾回踩",if retraced{1.0}else{0.0},">",0.0);
check(&mut result.checks,"收盘重新站回突破位",c,">=",level);
check(&mut result.checks,"收盘不低于昨日",c,">=",prices[len-2].3);
if prices[i].4<=0.0{return Err("突破锚日成交量为零,不能计算缩量比例".into());}
check(&mut result.checks,"相对突破日缩量",v/prices[i].4,"<=",spec.v("shrink_ratio"));
score=Some(c/level-1.0);
}
}
"limit_consolidation" => {
let i=len-1-spec.n("anchor_lag");let anchor=by_day[&days[i]];
let is_limit=if anchor.no_limit==Some(true){false}else{
let upper=number(anchor.upper_limit,&series.symbol,days[i],"upper_limit")?;
if upper<=0.0||upper>=99999.0{return Err("涨停事件缺少有效源涨停价或无涨跌幅限制证据,禁止按比例推算".into());}
(anchor.close.unwrap()/upper-1.0).abs()<=1e-8
};
if prices[i].4<=0.0{return Err("涨停锚日成交量为零".into());}
let price_gap=prices[i+1..].iter().map(|b|(b.3/prices[i].3-1.0).abs()).fold(0.0,f64::max);
let volume_gap=prices[i+1..].iter().map(|b|(b.4/prices[i].4-1.0).abs()).fold(0.0,f64::max);
let avg=mean(prices[len-spec.n("ma_window")..].iter().map(|b|b.3))?;
result.values["limit_date"]=json!(days[i]);result.values["price_deviation"]=json!(price_gap);result.values["volume_deviation"]=json!(volume_gap);
check(&mut result.checks,"锚日真实涨停收盘",if is_limit{1.0}else{0.0},">",0.0);
check(&mut result.checks,"后续收盘最大偏离",price_gap,"<=",spec.v("price_band"));
check(&mut result.checks,"后续成交量最大偏离",volume_gap,"<=",spec.v("volume_band"));
check(&mut result.checks,"收盘低于日线均线",c,"<",avg);score=Some(avg/c-1.0);
} }
_ => unreachable!(), _ => unreachable!(),
} }
@@ -553,6 +603,8 @@ pub fn evaluate_dataset(
volume: Some(b.volume as f64), volume: Some(b.volume as f64),
prev_close: data.factor_numeric_value(d, symbol, "pre_close"), prev_close: data.factor_numeric_value(d, symbol, "pre_close"),
amount: data.factor_numeric_value(d, symbol, "amount"), amount: data.factor_numeric_value(d, symbol, "amount"),
upper_limit: Some(b.upper_limit),
no_limit: data.factor_numeric_value(d,symbol,"no_limit").map(|v|v==1.0),
adjustment_factor_backward1: data adjustment_factor_backward1: data
.factor(d, symbol) .factor(d, symbol)
.and_then(|f| f.adjustment_factor_backward1), .and_then(|f| f.adjustment_factor_backward1),
@@ -662,6 +714,8 @@ mod tests {
volume: Some(100.0), volume: Some(100.0),
prev_close: Some(p - 1.0), prev_close: Some(p - 1.0),
amount: Some(p * 100.0), amount: Some(p * 100.0),
upper_limit: None,
no_limit: None,
adjustment_factor_backward1: Some(1.0), adjustment_factor_backward1: Some(1.0),
paused: Some(false), paused: Some(false),
source_path: None, source_path: None,
@@ -725,6 +779,8 @@ mod tests {
volume: Some(1000.0), volume: Some(1000.0),
prev_close: Some(c - 1.0), prev_close: Some(c - 1.0),
amount: Some(c * 1000.0), amount: Some(c * 1000.0),
upper_limit: None,
no_limit: None,
adjustment_factor_backward1: Some(1.0), adjustment_factor_backward1: Some(1.0),
paused: Some(false), paused: Some(false),
source_path: Some("fixture.parquet".into()), source_path: Some("fixture.parquet".into()),
@@ -775,6 +831,51 @@ mod tests {
assert_eq!(a.checks, b.checks); assert_eq!(a.checks, b.checks);
} }
#[test]
fn mean_volume_is_not_prior_max_and_excludes_current_bar() {
let (spec,days,mut series)=fixture("mean_volume_spike");
for (bar,volume) in series.bars.iter_mut().zip([10.,10.,10.,10.,100.,100.]) {bar.volume=Some(volume);}
assert!(evaluate(&spec,&days,&series).unwrap().matched);
let mut old=spec.clone();old.template="volume_spike".into();
assert!(!evaluate(&old,&days,&series).unwrap().matched);
assert_eq!(evaluate(&spec,&days,&series).unwrap().values["volume_ratio"],json!(100./28.));
}
#[test]
fn mean_volume_followup_requires_bullish_breakout_and_shrink() {
let (spec,days,mut series)=fixture("mean_shrink_breakout");
series.bars[6].volume=Some(4000.);
let last=series.bars.last_mut().unwrap();last.open=Some(19.);last.low=Some(19.);
assert!(evaluate(&spec,&days,&series).unwrap().matched);
series.bars.last_mut().unwrap().volume=Some(3000.);
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
}
#[test]
fn breakout_retest_needs_a_later_retest_not_the_breakout_candle_itself() {
let (spec,days,mut series)=fixture("breakout_retest");
for b in &mut series.bars {b.open=Some(10.);b.high=Some(10.);b.low=Some(10.);b.close=Some(10.);}
let anchor=series.bars.len()-11;
let b=&mut series.bars[anchor];b.open=Some(11.);b.high=Some(12.1);b.low=Some(9.9);b.close=Some(12.);b.volume=Some(2000.);
for b in &mut series.bars[anchor+1..] {b.open=Some(10.3);b.high=Some(10.4);b.low=Some(10.3);b.close=Some(10.4);}
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
series.bars[anchor+1].low=Some(9.95);
assert!(evaluate(&spec,&days,&series).unwrap().matched);
}
#[test]
fn limit_consolidation_requires_real_limit_and_never_infers_ten_percent() {
let (spec,days,mut series)=fixture("limit_consolidation");
for (b,c) in series.bars.iter_mut().zip([10.,10.1,10.2,10.1,9.9]) {b.open=Some(c);b.high=Some(c);b.low=Some(c);b.close=Some(c);}
assert!(evaluate(&spec,&days,&series).unwrap_err().contains("upper_limit"));
series.bars[0].upper_limit=Some(10.);
assert!(evaluate(&spec,&days,&series).unwrap().matched);
series.bars[0].no_limit=Some(true);
assert!(!evaluate(&spec,&days,&series).unwrap().matched);
series.bars[0].no_limit=Some(false);series.bars[0].upper_limit=Some(0.);
assert!(evaluate(&spec,&days,&series).is_err());
}
#[test] #[test]
fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() { fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() {
let (mut spec, _, mut series) = fixture("strength"); let (mut spec, _, mut series) = fixture("strength");