From 3b5a7cd318f62161514d4a743c8c5d54897f4642 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 07:03:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=81=92=E5=AE=9A=E5=B0=8F?= =?UTF-8?q?=E6=95=B0=E4=BB=B7=E6=A0=BC=E7=B4=AF=E5=8A=A0=E8=AF=AF=E5=B7=AE?= =?UTF-8?q?=E4=BA=A7=E7=94=9F=E8=99=9A=E5=81=87=E5=9D=87=E7=BA=BF=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/daily_patterns.rs | 63 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/fidc-core/src/daily_patterns.rs b/crates/fidc-core/src/daily_patterns.rs index 80885f7..0530152 100644 --- a/crates/fidc-core/src/daily_patterns.rs +++ b/crates/fidc-core/src/daily_patterns.rs @@ -126,6 +126,20 @@ fn check(checks: &mut Vec, label: &str, actual: f64, operator: &str, thre checks.push(json!({"label":label,"actual":actual,"operator":operator,"threshold":threshold,"passed":passed})); } +fn mean(mut values: impl ExactSizeIterator) -> Result { + let count = values.len(); + let first = values.next().ok_or("pattern_mean_empty")?; + // Center before summation so an unchanged decimal price stays exactly unchanged. + let result = first + + values + .map(|value| (value - first) / count as f64) + .sum::(); + if !result.is_finite() { + return Err("pattern_mean_nonfinite".into()); + } + Ok(result) +} + /// No calendar compression, fill-forward prices or numerical substitutes. pub fn evaluate( spec: &PatternSpec, @@ -233,16 +247,8 @@ pub fn evaluate( let mut score = None; match spec.template.as_str() { "strength" => { - let fast = prices[len - spec.n("fast_window")..] - .iter() - .map(|b| b.3) - .sum::() - / spec.n("fast_window") as f64; - let slow = prices[len - spec.n("slow_window")..] - .iter() - .map(|b| b.3) - .sum::() - / spec.n("slow_window") as f64; + let fast = mean(prices[len - spec.n("fast_window")..].iter().map(|b| b.3))?; + let slow = mean(prices[len - spec.n("slow_window")..].iter().map(|b| b.3))?; let momentum = c / prices[len - 1 - spec.n("momentum_window")].3 - 1.0; score = Some(momentum); result.values["momentum"] = json!(momentum); @@ -256,11 +262,11 @@ pub fn evaluate( .iter() .map(|b| b.1) .fold(f64::NEG_INFINITY, f64::max); - let avg = prices[len - 1 - spec.n("volume_window")..len - 1] - .iter() - .map(|b| b.4) - .sum::() - / spec.n("volume_window") as f64; + let avg = mean( + prices[len - 1 - spec.n("volume_window")..len - 1] + .iter() + .map(|b| b.4), + )?; if avg <= 0.0 { return Err(format!( "pattern_input_invalid: symbol={}, reason=zero_reference_volume", @@ -324,11 +330,7 @@ pub fn evaluate( ); } "ma_below" => { - let avg = prices[len - spec.n("ma_window")..] - .iter() - .map(|b| b.3) - .sum::() - / spec.n("ma_window") as f64; + let avg = mean(prices[len - spec.n("ma_window")..].iter().map(|b| b.3))?; score = Some(avg / c - 1.0); result.values["ma"] = json!(avg); check(&mut result.checks, "收盘低于均线", c, "<", avg); @@ -562,6 +564,27 @@ mod tests { assert_eq!(a.score, b.score); assert_eq!(a.checks, b.checks); } + + #[test] + fn daily_patterns_flat_decimal_prices_do_not_create_a_sell_signal() { + let (mut spec, _, mut series) = fixture("strength"); + spec.template = "ma_below".into(); + spec.parameters = BTreeMap::from([("ma_window".into(), json!(60))]); + for bar in &mut series.bars { + bar.open = Some(10.1); + bar.high = Some(10.1); + bar.low = Some(10.1); + bar.close = Some(10.1); + } + let days = series.bars.iter().map(|bar| bar.date).collect::>(); + let result = evaluate(&spec, &days, &series).unwrap(); + assert!( + !result.matched, + "unchanged decimal prices must not trigger a below-MA sell: {:?}", + result.checks + ); + assert_eq!(result.values["ma"], 10.1); + } #[test] fn daily_patterns_no_missing_data_fallback() { let (spec, days, mut series) = fixture("strength");