use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Timeframe {
M1,
M5,
M15,
H1,
D1,
}
impl Timeframe {
pub const ALL: [Timeframe; 5] = [
Timeframe::M1,
Timeframe::M5,
Timeframe::M15,
Timeframe::H1,
Timeframe::D1,
];
pub fn label(&self) -> &'static str {
match self {
Timeframe::M1 => "M1",
Timeframe::M5 => "M5",
Timeframe::M15 => "M15",
Timeframe::H1 => "H1",
Timeframe::D1 => "D1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SignalDirection {
Bullish,
Bearish,
Neutral,
}
pub trait TimeframeIndicator: Send {
fn update(&mut self, close: f64) -> Option<SignalDirection>;
fn reset(&mut self);
}
pub type BoxedIndicator = Box<dyn TimeframeIndicator>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AgreementPolicy {
Unanimous,
MajorityVote,
AtLeast(usize),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum MultiTfResult {
Confirmed {
direction: SignalDirection,
agreement_count: usize,
active_count: usize,
},
Insufficient {
votes: HashMap<String, Option<SignalDirection>>,
},
}
struct TfSlot {
timeframe: Timeframe,
indicator: BoxedIndicator,
last_direction: Option<SignalDirection>,
}
pub struct MultiTimeframeSignal {
policy: AgreementPolicy,
slots: Vec<TfSlot>,
}
impl MultiTimeframeSignal {
pub fn new(policy: AgreementPolicy) -> Self {
Self { policy, slots: Vec::new() }
}
pub fn add_timeframe(&mut self, tf: Timeframe, indicator: BoxedIndicator) {
if let Some(slot) = self.slots.iter_mut().find(|s| s.timeframe == tf) {
slot.indicator = indicator;
slot.last_direction = None;
} else {
self.slots.push(TfSlot { timeframe: tf, indicator, last_direction: None });
}
}
pub fn update_timeframe(&mut self, tf: Timeframe, close: f64) -> MultiTfResult {
if let Some(slot) = self.slots.iter_mut().find(|s| s.timeframe == tf) {
if let Some(dir) = slot.indicator.update(close) {
slot.last_direction = Some(dir);
}
}
let active: Vec<(Timeframe, SignalDirection)> = self
.slots
.iter()
.filter_map(|s| s.last_direction.map(|d| (s.timeframe, d)))
.collect();
let active_count = active.len();
if active_count == 0 {
let votes = self.current_vote_map();
return MultiTfResult::Insufficient { votes };
}
let bullish = active.iter().filter(|(_, d)| *d == SignalDirection::Bullish).count();
let bearish = active.iter().filter(|(_, d)| *d == SignalDirection::Bearish).count();
let (winner_dir, winner_count) = if bullish >= bearish {
(SignalDirection::Bullish, bullish)
} else {
(SignalDirection::Bearish, bearish)
};
let threshold_met = match self.policy {
AgreementPolicy::Unanimous => winner_count == active_count,
AgreementPolicy::MajorityVote => winner_count * 2 > active_count,
AgreementPolicy::AtLeast(n) => winner_count >= n,
};
if threshold_met && winner_count > 0 {
MultiTfResult::Confirmed {
direction: winner_dir,
agreement_count: winner_count,
active_count,
}
} else {
MultiTfResult::Insufficient { votes: self.current_vote_map() }
}
}
pub fn reset_all(&mut self) {
for slot in &mut self.slots {
slot.indicator.reset();
slot.last_direction = None;
}
}
pub fn timeframe_count(&self) -> usize {
self.slots.len()
}
pub fn active_count(&self) -> usize {
self.slots.iter().filter(|s| s.last_direction.is_some()).count()
}
fn current_vote_map(&self) -> HashMap<String, Option<SignalDirection>> {
self.slots
.iter()
.map(|s| (s.timeframe.label().to_owned(), s.last_direction))
.collect()
}
}
#[derive(Debug, Default)]
pub struct MomentumIndicator {
prev: Option<f64>,
}
impl MomentumIndicator {
pub fn new() -> Self {
Self { prev: None }
}
}
impl TimeframeIndicator for MomentumIndicator {
fn update(&mut self, close: f64) -> Option<SignalDirection> {
let prev = self.prev.replace(close)?;
if close > prev {
Some(SignalDirection::Bullish)
} else if close < prev {
Some(SignalDirection::Bearish)
} else {
Some(SignalDirection::Neutral)
}
}
fn reset(&mut self) {
self.prev = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_mts(policy: AgreementPolicy) -> MultiTimeframeSignal {
let mut mts = MultiTimeframeSignal::new(policy);
for tf in Timeframe::ALL {
mts.add_timeframe(tf, Box::new(MomentumIndicator::new()));
}
mts
}
fn seed(mts: &mut MultiTimeframeSignal, price: f64) {
for tf in Timeframe::ALL {
mts.update_timeframe(tf, price);
}
}
#[test]
fn test_insufficient_before_warmup() {
let mut mts = make_mts(AgreementPolicy::Unanimous);
let result = mts.update_timeframe(Timeframe::M1, 100.0);
assert!(matches!(result, MultiTfResult::Insufficient { .. }));
}
#[test]
fn test_unanimous_bullish_confirmed() {
let mut mts = make_mts(AgreementPolicy::Unanimous);
seed(&mut mts, 100.0); for tf in Timeframe::ALL {
mts.update_timeframe(tf, 101.0);
}
let result = mts.update_timeframe(Timeframe::D1, 101.0);
assert!(
matches!(result, MultiTfResult::Confirmed { direction: SignalDirection::Bullish, .. }),
"expected Confirmed Bullish, got {result:?}"
);
}
#[test]
fn test_majority_vote_with_one_disagreement() {
let mut mts = make_mts(AgreementPolicy::MajorityVote);
seed(&mut mts, 100.0);
for &tf in &[Timeframe::M1, Timeframe::M5, Timeframe::M15, Timeframe::H1] {
mts.update_timeframe(tf, 105.0);
}
let result = mts.update_timeframe(Timeframe::D1, 95.0); assert!(
matches!(result, MultiTfResult::Confirmed { direction: SignalDirection::Bullish, .. }),
"expected Confirmed Bullish majority, got {result:?}"
);
}
#[test]
fn test_unanimous_fails_on_disagreement() {
let mut mts = make_mts(AgreementPolicy::Unanimous);
seed(&mut mts, 100.0);
for &tf in &[Timeframe::M1, Timeframe::M5, Timeframe::M15, Timeframe::H1] {
mts.update_timeframe(tf, 110.0);
}
let result = mts.update_timeframe(Timeframe::D1, 90.0);
assert!(matches!(result, MultiTfResult::Insufficient { .. }));
}
#[test]
fn test_timeframe_count() {
let mts = make_mts(AgreementPolicy::MajorityVote);
assert_eq!(mts.timeframe_count(), 5);
}
#[test]
fn test_reset_all() {
let mut mts = make_mts(AgreementPolicy::Unanimous);
seed(&mut mts, 100.0);
seed(&mut mts, 105.0);
assert!(mts.active_count() > 0);
mts.reset_all();
assert_eq!(mts.active_count(), 0);
}
}