use std::collections::HashMap;
use crate::backtesting::condition::{Condition, HtfIndicatorSpec};
use crate::backtesting::engine::compute_for_candles;
use crate::backtesting::resample::resample;
use crate::backtesting::strategy::StrategyContext;
use crate::constants::{Interval, Region};
use crate::indicators::Indicator;
#[derive(Clone)]
pub struct HtfCondition<C: Condition> {
interval: Interval,
inner: C,
utc_offset_secs: i64,
specs: Vec<HtfIndicatorSpec>,
}
impl<C: Condition> Condition for HtfCondition<C> {
fn evaluate(&self, ctx: &StrategyContext) -> bool {
if !self.specs.is_empty() {
let mut mini_indicators: HashMap<String, Vec<Option<f64>>> =
HashMap::with_capacity(self.specs.len());
let mut all_found = true;
for spec in &self.specs {
if let Some(stretched) = ctx.indicators.get(&spec.htf_key) {
let curr = stretched.get(ctx.index).copied().flatten();
let prev = ctx
.index
.checked_sub(1)
.and_then(|pi| stretched.get(pi).copied().flatten());
mini_indicators.insert(spec.base_key.clone(), vec![prev, curr]);
} else {
all_found = false;
break;
}
}
if all_found {
let htf_ctx = StrategyContext {
candles: ctx.candles,
index: ctx.index,
position: ctx.position,
equity: ctx.equity,
indicators: &mini_indicators,
extremes: ctx.extremes,
indicator_index: Some(1),
};
return self.inner.evaluate(&htf_ctx);
}
} else {
return self.inner.evaluate(ctx);
}
tracing::warn!(
interval = %self.interval,
"HtfCondition falling back to O(n²) dynamic resampling — \
implement Strategy::htf_requirements() or use StrategyBuilder \
to enable O(1) pre-computed HTF lookups"
);
self.evaluate_dynamic(ctx)
}
fn required_indicators(&self) -> Vec<(String, Indicator)> {
vec![]
}
fn htf_requirements(&self) -> Vec<HtfIndicatorSpec> {
self.specs.clone()
}
fn tracks_position_extremes(&self) -> bool {
self.inner.tracks_position_extremes()
}
fn description(&self) -> String {
format!("htf({}, {})", self.interval, self.inner.description())
}
}
impl<C: Condition> HtfCondition<C> {
fn new(interval: Interval, inner: C, utc_offset_secs: i64) -> Self {
let interval_str = interval.as_str();
let specs = inner
.required_indicators()
.into_iter()
.map(|(base_key, indicator)| HtfIndicatorSpec {
interval,
htf_key: format!("htf_{}_{}", interval_str, base_key),
base_key,
indicator,
utc_offset_secs,
})
.collect();
Self {
interval,
inner,
utc_offset_secs,
specs,
}
}
fn evaluate_dynamic(&self, ctx: &StrategyContext) -> bool {
let htf_candles = resample(ctx.candles, self.interval, self.utc_offset_secs);
let required = self
.specs
.iter()
.map(|s| (s.base_key.clone(), s.indicator))
.collect();
let htf_indicators = match compute_for_candles(&htf_candles, required) {
Ok(map) => map,
Err(e) => {
tracing::warn!("HTF indicator computation failed: {}", e);
return false;
}
};
let last_completed = |ts: i64| htf_candles.iter().rposition(|c| c.timestamp < ts);
let curr_idx = last_completed(ctx.current_candle().timestamp);
let prev_idx = ctx
.index
.checked_sub(1)
.and_then(|pi| last_completed(ctx.candles[pi].timestamp));
let mut mini_indicators: HashMap<String, Vec<Option<f64>>> =
HashMap::with_capacity(self.specs.len());
for spec in &self.specs {
let series = htf_indicators.get(&spec.base_key);
let at = |idx: Option<usize>| {
idx.and_then(|i| series.and_then(|v| v.get(i)).copied().flatten())
};
mini_indicators.insert(spec.base_key.clone(), vec![at(prev_idx), at(curr_idx)]);
}
let htf_ctx = StrategyContext {
candles: ctx.candles,
index: ctx.index,
position: ctx.position,
equity: ctx.equity,
indicators: &mini_indicators,
extremes: ctx.extremes,
indicator_index: Some(1),
};
self.inner.evaluate(&htf_ctx)
}
}
pub fn htf<C: Condition>(interval: Interval, cond: C) -> HtfCondition<C> {
HtfCondition::new(interval, cond, 0)
}
pub fn htf_region<C: Condition>(interval: Interval, region: Region, cond: C) -> HtfCondition<C> {
HtfCondition::new(interval, cond, region.utc_offset_secs())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backtesting::condition::held_for_bars;
use crate::backtesting::config::BacktestConfig;
use crate::backtesting::engine::BacktestEngine;
use crate::backtesting::position::{Position, PositionSide};
use crate::backtesting::refs::{IndicatorRefExt, price, relative_volume, sma};
use crate::backtesting::signal::Signal;
use crate::backtesting::strategy::StrategyBuilder;
use crate::models::chart::Candle;
const DAY: i64 = 86_400;
fn daily_candles(prices: &[f64]) -> Vec<Candle> {
prices
.iter()
.enumerate()
.map(|(i, &p)| Candle {
timestamp: 4 * DAY + i as i64 * DAY,
open: p,
high: p * 1.01,
low: p * 0.99,
close: p,
volume: 1_000,
adj_close: Some(p),
provider_id: None,
})
.collect()
}
fn zigzag_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| {
let t = i as f64;
100.0 + 20.0 * (t / 14.0).sin() + t * 0.05
})
.collect()
}
fn run_htf_backtest() -> Vec<(i64, i64, f64, f64)> {
let candles = daily_candles(&zigzag_prices(180));
let config = BacktestConfig::builder()
.commission_pct(0.0)
.slippage_pct(0.0)
.build()
.unwrap();
let strategy = StrategyBuilder::new("HTF Characterization")
.entry(htf(Interval::OneWeek, price().above_ref(sma(3))))
.exit(htf(Interval::OneWeek, price().below_ref(sma(3))))
.build();
let result = BacktestEngine::new(config)
.run("TEST", &candles, strategy)
.unwrap();
result
.trades
.iter()
.map(|t| {
(
t.entry_timestamp,
t.exit_timestamp,
(t.entry_price * 1e6).round() / 1e6,
(t.exit_price * 1e6).round() / 1e6,
)
})
.collect()
}
#[test]
fn test_htf_backtest_trade_sequence_is_stable() {
let expected = vec![
(2_160_000, 2_937_600, 120.9999, 118.315742),
(6_739_200, 10_627_200, 86.897962, 121.919742),
(14_256_000, 15_811_200, 90.540957, 113.301781),
];
assert_eq!(run_htf_backtest(), expected);
}
#[test]
fn test_precomputed_keys_match_htf_requirements() {
let cond = htf(Interval::OneWeek, price().above_ref(sma(20)));
let specs = cond.htf_requirements();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].base_key, "sma_20");
assert_eq!(specs[0].htf_key, "htf_1wk_sma_20");
}
#[test]
fn test_pure_price_condition_has_no_htf_requirements() {
let cond = htf(Interval::OneWeek, price().above(100.0));
assert!(cond.htf_requirements().is_empty());
}
#[test]
fn test_mixed_condition_keeps_base_candle_history() {
let candles = daily_candles(&[100.0; 30]);
let mut indicators = HashMap::new();
indicators.insert("htf_1wk_sma_3".to_string(), vec![Some(1.0); 30]);
let cond = htf(
Interval::OneWeek,
sma(3).above(0.0).and(relative_volume(5).above(0.5)),
);
let ctx = StrategyContext {
candles: &candles,
index: 20,
position: None,
equity: 10_000.0,
indicators: &indicators,
extremes: None,
indicator_index: None,
};
assert!(cond.evaluate(&ctx));
}
#[test]
fn test_position_condition_sees_base_history() {
let candles = daily_candles(&[100.0; 30]);
let mut indicators = HashMap::new();
indicators.insert("htf_1wk_sma_3".to_string(), vec![Some(1.0); 30]);
let entry_ts = candles[10].timestamp;
let pos = Position::new(
PositionSide::Long,
entry_ts,
100.0,
10.0,
0.0,
Signal::long(entry_ts, 100.0),
);
let cond = htf(Interval::OneWeek, sma(3).above(0.0).and(held_for_bars(3)));
let ctx = StrategyContext {
candles: &candles,
index: 15,
position: Some(&pos),
equity: 10_000.0,
indicators: &indicators,
extremes: None,
indicator_index: None,
};
assert!(cond.evaluate(&ctx));
}
#[test]
fn test_fast_path_reads_curr_slot_at_bar_0() {
let candles = daily_candles(&[100.0]);
let mut indicators = HashMap::new();
indicators.insert("htf_1wk_sma_3".to_string(), vec![Some(5.0)]);
let cond = htf(Interval::OneWeek, sma(3).above(0.0));
let ctx = StrategyContext {
candles: &candles,
index: 0,
position: None,
equity: 10_000.0,
indicators: &indicators,
extremes: None,
indicator_index: None,
};
assert!(cond.evaluate(&ctx));
}
#[test]
fn test_dynamic_fallback_price_refs_stay_on_base_bars() {
let mut prices = vec![99.0; 14];
prices.push(101.0);
let candles = daily_candles(&prices);
let indicators = HashMap::new();
let cond = htf(
Interval::OneWeek,
price().above(100.0).and(sma(1).above(0.0)),
);
let ctx = StrategyContext {
candles: &candles,
index: 14,
position: None,
equity: 10_000.0,
indicators: &indicators,
extremes: None,
indicator_index: None,
};
assert!(cond.evaluate(&ctx));
}
}