Skip to main content

finance_query/backtesting/refs/
htf.rs

1//! Higher-timeframe (HTF) condition wrapper.
2//!
3//! [`htf()`] wraps any [`Condition`] to evaluate it on a resampled
4//! higher-timeframe candle series, enabling multi-timeframe confirmation
5//! without look-ahead bias. Use [`htf_region()`] when the underlying instrument
6//! trades on a non-UTC exchange (e.g. Tokyo, Hong Kong) to ensure weekly and
7//! monthly bucket boundaries align with the local calendar.
8//!
9//! # How it works — fast path (engine pre-computation)
10//!
11//! When the strategy is built with [`StrategyBuilder`], the engine pre-computes
12//! all HTF indicator arrays once before the main simulation loop:
13//!
14//! 1. Collects [`HtfIndicatorSpec`]s from `HtfCondition::htf_requirements()`.
15//! 2. Resamples the full candle history to each unique HTF interval **once**.
16//! 3. Computes the inner indicators on the resampled data.
17//! 4. Stretches results back to base-timeframe length via `base_to_htf_index`.
18//! 5. Stores stretched arrays in `StrategyContext::indicators` under `htf_key`.
19//!
20//! On each bar, `evaluate()` does only O(k) work (k = # inner indicators):
21//! it reads the pre-computed values, builds a tiny 2-element indicator map, and
22//! evaluates the inner condition. HTF crossovers work correctly because the map
23//! stores `[prev, current]`. The inner condition keeps the full base candle
24//! history and index, so price-action refs, candle-window refs, and position
25//! conditions all stay on the base timeframe; only indicator refs read HTF
26//! values.
27//!
28//! # Fallback (dynamic resampling)
29//!
30//! If the pre-computed arrays are not found in `ctx.indicators` (e.g. a raw
31//! [`Strategy`] implementation that doesn't forward `htf_requirements()`), the
32//! condition falls back to dynamic resampling — O(n) per bar, O(n²) total —
33//! with the same context shape as the fast path. One divergence is inherent:
34//! the fallback sees only candles up to the current bar, so it counts an HTF
35//! bar as completed one base bar later than the fast path does on the bucket's
36//! final bar. A `tracing::warn!` is emitted once per evaluation so the caller
37//! can diagnose the performance issue.
38//!
39//! # Example
40//!
41//! ```ignore
42//! use finance_query::backtesting::{StrategyBuilder, BacktestConfig, BacktestEngine};
43//! use finance_query::backtesting::refs::*;
44//! use finance_query::Interval;
45//!
46//! // Enter only when daily EMA10 crosses EMA30 AND weekly price > SMA20
47//! let strategy = StrategyBuilder::new("MTF Confirmation")
48//!     .entry(
49//!         ema(10).crosses_above_ref(ema(30))
50//!             .and(htf(Interval::OneWeek, price().above_ref(sma(20))))
51//!     )
52//!     .exit(ema(10).crosses_below_ref(ema(30)))
53//!     .build();
54//! ```
55//!
56//! For a Tokyo Stock Exchange strategy:
57//!
58//! ```ignore
59//! use finance_query::backtesting::refs::*;
60//! use finance_query::{Interval, Region};
61//!
62//! let weekly_trend = htf_region(Interval::OneWeek, Region::Japan, price().above_ref(sma(20)));
63//! ```
64//!
65//! [`StrategyBuilder`]: crate::backtesting::strategy::StrategyBuilder
66//! [`Strategy`]: crate::backtesting::strategy::Strategy
67
68use std::collections::HashMap;
69
70use crate::backtesting::condition::{Condition, HtfIndicatorSpec};
71use crate::backtesting::engine::compute_for_candles;
72use crate::backtesting::resample::resample;
73use crate::backtesting::strategy::StrategyContext;
74use crate::constants::{Interval, Region};
75use crate::indicators::Indicator;
76
77/// A condition that evaluates its inner condition on a resampled HTF candle series.
78///
79/// Created by [`htf()`] (UTC-aligned) or [`htf_region()`] (exchange-local calendar).
80#[derive(Clone)]
81pub struct HtfCondition<C: Condition> {
82    interval: Interval,
83    inner: C,
84    /// UTC offset of the exchange. Shifts bucket boundaries so weekly/monthly
85    /// periods align with the exchange's local calendar rather than UTC midnight.
86    utc_offset_secs: i64,
87    /// Derived once at construction from `inner.required_indicators()`. Purely a
88    /// function of `self`, so `evaluate` never has to re-walk the condition tree
89    /// or re-format lookup keys on a per-bar basis.
90    specs: Vec<HtfIndicatorSpec>,
91}
92
93impl<C: Condition> Condition for HtfCondition<C> {
94    fn evaluate(&self, ctx: &StrategyContext) -> bool {
95        // ── Fast path: use pre-computed stretched arrays from the engine ──────
96        // The engine stores stretched HTF values in ctx.indicators under keys of
97        // the form "htf_{interval}_{base_key}" (e.g. "htf_1wk_sma_20"), which is
98        // exactly what `self.specs` holds.
99        //
100        // We build a tiny 2-element indicators map [prev, curr] so that the inner
101        // condition's crossover helpers (indicator_prev / crossed_above etc.) work
102        // correctly, then evaluate with indicator_index pinned to the curr slot.
103        if !self.specs.is_empty() {
104            let mut mini_indicators: HashMap<String, Vec<Option<f64>>> =
105                HashMap::with_capacity(self.specs.len());
106            let mut all_found = true;
107
108            for spec in &self.specs {
109                if let Some(stretched) = ctx.indicators.get(&spec.htf_key) {
110                    let curr = stretched.get(ctx.index).copied().flatten();
111                    let prev = ctx
112                        .index
113                        .checked_sub(1)
114                        .and_then(|pi| stretched.get(pi).copied().flatten());
115                    mini_indicators.insert(spec.base_key.clone(), vec![prev, curr]);
116                } else {
117                    all_found = false;
118                    break;
119                }
120            }
121
122            if all_found {
123                // Full base candle history with indicator_index pinned to the
124                // [prev, curr] map: price refs and position conditions keep
125                // base-bar indexing while indicator refs read the HTF pair.
126                let htf_ctx = StrategyContext {
127                    candles: ctx.candles,
128                    index: ctx.index,
129                    position: ctx.position,
130                    equity: ctx.equity,
131                    indicators: &mini_indicators,
132                    extremes: ctx.extremes,
133                    indicator_index: Some(1),
134                };
135                return self.inner.evaluate(&htf_ctx);
136            }
137        } else {
138            // Pure price condition — no HTF indicators needed.
139            // Evaluate directly so price() reads from the current base bar.
140            return self.inner.evaluate(ctx);
141        }
142
143        // ── Fallback: dynamic resampling (O(n) per bar → O(n²) total) ────────
144        // Reached only when the strategy does not implement htf_requirements()
145        // (e.g. a raw Strategy impl). StrategyBuilder-based strategies never hit
146        // this path because the engine pre-computes the stretched arrays.
147        tracing::warn!(
148            interval = %self.interval,
149            "HtfCondition falling back to O(n²) dynamic resampling — \
150             implement Strategy::htf_requirements() or use StrategyBuilder \
151             to enable O(1) pre-computed HTF lookups"
152        );
153        self.evaluate_dynamic(ctx)
154    }
155
156    fn required_indicators(&self) -> Vec<(String, Indicator)> {
157        // HtfCondition resolves its own indicators on resampled data.
158        // The main engine must NOT pre-compute these on the base-TF candles.
159        vec![]
160    }
161
162    fn htf_requirements(&self) -> Vec<HtfIndicatorSpec> {
163        self.specs.clone()
164    }
165
166    fn tracks_position_extremes(&self) -> bool {
167        self.inner.tracks_position_extremes()
168    }
169
170    fn description(&self) -> String {
171        format!("htf({}, {})", self.interval, self.inner.description())
172    }
173}
174
175impl<C: Condition> HtfCondition<C> {
176    fn new(interval: Interval, inner: C, utc_offset_secs: i64) -> Self {
177        let interval_str = interval.as_str();
178        let specs = inner
179            .required_indicators()
180            .into_iter()
181            .map(|(base_key, indicator)| HtfIndicatorSpec {
182                interval,
183                htf_key: format!("htf_{}_{}", interval_str, base_key),
184                base_key,
185                indicator,
186                utc_offset_secs,
187            })
188            .collect();
189        Self {
190            interval,
191            inner,
192            utc_offset_secs,
193            specs,
194        }
195    }
196
197    /// Dynamic resampling fallback used when pre-computed data is unavailable.
198    ///
199    /// O(n) per bar (O(n²) overall). Mirrors the fast path's context shape:
200    /// indicator refs read a `[prev, curr]` HTF pair while price refs and
201    /// position conditions stay on the base bars. Only candles up to
202    /// `ctx.index` exist here, so an HTF bar counts as completed one base bar
203    /// later than in the fast path (`<` instead of `<=`): the final resampled
204    /// bucket may still be partial and nothing in the slice can prove it closed.
205    fn evaluate_dynamic(&self, ctx: &StrategyContext) -> bool {
206        let htf_candles = resample(ctx.candles, self.interval, self.utc_offset_secs);
207
208        let required = self
209            .specs
210            .iter()
211            .map(|s| (s.base_key.clone(), s.indicator))
212            .collect();
213        let htf_indicators = match compute_for_candles(&htf_candles, required) {
214            Ok(map) => map,
215            Err(e) => {
216                tracing::warn!("HTF indicator computation failed: {}", e);
217                return false;
218            }
219        };
220
221        let last_completed = |ts: i64| htf_candles.iter().rposition(|c| c.timestamp < ts);
222        let curr_idx = last_completed(ctx.current_candle().timestamp);
223        let prev_idx = ctx
224            .index
225            .checked_sub(1)
226            .and_then(|pi| last_completed(ctx.candles[pi].timestamp));
227
228        let mut mini_indicators: HashMap<String, Vec<Option<f64>>> =
229            HashMap::with_capacity(self.specs.len());
230        for spec in &self.specs {
231            let series = htf_indicators.get(&spec.base_key);
232            let at = |idx: Option<usize>| {
233                idx.and_then(|i| series.and_then(|v| v.get(i)).copied().flatten())
234            };
235            mini_indicators.insert(spec.base_key.clone(), vec![at(prev_idx), at(curr_idx)]);
236        }
237
238        let htf_ctx = StrategyContext {
239            candles: ctx.candles,
240            index: ctx.index,
241            position: ctx.position,
242            equity: ctx.equity,
243            indicators: &mini_indicators,
244            extremes: ctx.extremes,
245            indicator_index: Some(1),
246        };
247        self.inner.evaluate(&htf_ctx)
248    }
249}
250
251/// Wrap a condition to be evaluated on a higher-timeframe candle series.
252///
253/// Bucket boundaries are UTC-aligned (offset = 0). For non-UTC exchanges use
254/// [`htf_region()`] instead.
255///
256/// # Arguments
257///
258/// * `interval` – Target higher timeframe (e.g. `Interval::OneWeek`)
259/// * `cond` – Any condition to evaluate on the HTF candles
260///
261/// # Example
262///
263/// ```ignore
264/// use finance_query::backtesting::refs::*;
265/// use finance_query::Interval;
266///
267/// // Entry only when weekly price is above its 20-bar SMA
268/// let weekly_uptrend = htf(Interval::OneWeek, price().above_ref(sma(20)));
269/// let entry = ema(10).crosses_above_ref(ema(30)).and(weekly_uptrend);
270/// ```
271pub fn htf<C: Condition>(interval: Interval, cond: C) -> HtfCondition<C> {
272    HtfCondition::new(interval, cond, 0)
273}
274
275/// Wrap a condition to be evaluated on a higher-timeframe candle series,
276/// with bucket boundaries aligned to the exchange's local calendar.
277///
278/// Weekly and monthly boundaries are shifted by `region.utc_offset_secs()` so
279/// that, for example, a Tokyo-listed stock's "Monday" starts at the correct
280/// local midnight rather than UTC midnight.
281///
282/// # Arguments
283///
284/// * `interval` – Target higher timeframe (e.g. `Interval::OneWeek`)
285/// * `region`   – Exchange region used to derive the UTC offset
286/// * `cond`     – Any condition to evaluate on the HTF candles
287///
288/// # Example
289///
290/// ```ignore
291/// use finance_query::backtesting::refs::*;
292/// use finance_query::{Interval, Region};
293///
294/// let weekly_trend = htf_region(Interval::OneWeek, Region::Japan, price().above_ref(sma(20)));
295/// ```
296pub fn htf_region<C: Condition>(interval: Interval, region: Region, cond: C) -> HtfCondition<C> {
297    HtfCondition::new(interval, cond, region.utc_offset_secs())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::backtesting::condition::held_for_bars;
304    use crate::backtesting::config::BacktestConfig;
305    use crate::backtesting::engine::BacktestEngine;
306    use crate::backtesting::position::{Position, PositionSide};
307    use crate::backtesting::refs::{IndicatorRefExt, price, relative_volume, sma};
308    use crate::backtesting::signal::Signal;
309    use crate::backtesting::strategy::StrategyBuilder;
310    use crate::models::chart::Candle;
311
312    const DAY: i64 = 86_400;
313
314    /// Daily bars starting 1970-01-05 (a Monday) so weekly buckets are aligned.
315    fn daily_candles(prices: &[f64]) -> Vec<Candle> {
316        prices
317            .iter()
318            .enumerate()
319            .map(|(i, &p)| Candle {
320                timestamp: 4 * DAY + i as i64 * DAY,
321                open: p,
322                high: p * 1.01,
323                low: p * 0.99,
324                close: p,
325                volume: 1_000,
326                adj_close: Some(p),
327                provider_id: None,
328            })
329            .collect()
330    }
331
332    /// Rises, falls, then rises again — enough regime changes to produce
333    /// multiple weekly HTF crossings of the SMA.
334    fn zigzag_prices(n: usize) -> Vec<f64> {
335        (0..n)
336            .map(|i| {
337                let t = i as f64;
338                100.0 + 20.0 * (t / 14.0).sin() + t * 0.05
339            })
340            .collect()
341    }
342
343    fn run_htf_backtest() -> Vec<(i64, i64, f64, f64)> {
344        let candles = daily_candles(&zigzag_prices(180));
345        let config = BacktestConfig::builder()
346            .commission_pct(0.0)
347            .slippage_pct(0.0)
348            .build()
349            .unwrap();
350
351        let strategy = StrategyBuilder::new("HTF Characterization")
352            .entry(htf(Interval::OneWeek, price().above_ref(sma(3))))
353            .exit(htf(Interval::OneWeek, price().below_ref(sma(3))))
354            .build();
355
356        let result = BacktestEngine::new(config)
357            .run("TEST", &candles, strategy)
358            .unwrap();
359
360        result
361            .trades
362            .iter()
363            .map(|t| {
364                (
365                    t.entry_timestamp,
366                    t.exit_timestamp,
367                    (t.entry_price * 1e6).round() / 1e6,
368                    (t.exit_price * 1e6).round() / 1e6,
369                )
370            })
371            .collect()
372    }
373
374    /// Golden trade sequence. Precomputing the HTF lookup keys must not change
375    /// a single entry/exit.
376    #[test]
377    fn test_htf_backtest_trade_sequence_is_stable() {
378        let expected = vec![
379            (2_160_000, 2_937_600, 120.9999, 118.315742),
380            (6_739_200, 10_627_200, 86.897962, 121.919742),
381            (14_256_000, 15_811_200, 90.540957, 113.301781),
382        ];
383        assert_eq!(run_htf_backtest(), expected);
384    }
385
386    #[test]
387    fn test_precomputed_keys_match_htf_requirements() {
388        let cond = htf(Interval::OneWeek, price().above_ref(sma(20)));
389        let specs = cond.htf_requirements();
390        assert_eq!(specs.len(), 1);
391        assert_eq!(specs[0].base_key, "sma_20");
392        assert_eq!(specs[0].htf_key, "htf_1wk_sma_20");
393    }
394
395    #[test]
396    fn test_pure_price_condition_has_no_htf_requirements() {
397        let cond = htf(Interval::OneWeek, price().above(100.0));
398        assert!(cond.htf_requirements().is_empty());
399    }
400
401    #[test]
402    fn test_mixed_condition_keeps_base_candle_history() {
403        let candles = daily_candles(&[100.0; 30]);
404        let mut indicators = HashMap::new();
405        indicators.insert("htf_1wk_sma_3".to_string(), vec![Some(1.0); 30]);
406
407        let cond = htf(
408            Interval::OneWeek,
409            sma(3).above(0.0).and(relative_volume(5).above(0.5)),
410        );
411        let ctx = StrategyContext {
412            candles: &candles,
413            index: 20,
414            position: None,
415            equity: 10_000.0,
416            indicators: &indicators,
417            extremes: None,
418            indicator_index: None,
419        };
420        assert!(cond.evaluate(&ctx));
421    }
422
423    #[test]
424    fn test_position_condition_sees_base_history() {
425        let candles = daily_candles(&[100.0; 30]);
426        let mut indicators = HashMap::new();
427        indicators.insert("htf_1wk_sma_3".to_string(), vec![Some(1.0); 30]);
428        let entry_ts = candles[10].timestamp;
429        let pos = Position::new(
430            PositionSide::Long,
431            entry_ts,
432            100.0,
433            10.0,
434            0.0,
435            Signal::long(entry_ts, 100.0),
436        );
437
438        let cond = htf(Interval::OneWeek, sma(3).above(0.0).and(held_for_bars(3)));
439        let ctx = StrategyContext {
440            candles: &candles,
441            index: 15,
442            position: Some(&pos),
443            equity: 10_000.0,
444            indicators: &indicators,
445            extremes: None,
446            indicator_index: None,
447        };
448        assert!(cond.evaluate(&ctx));
449    }
450
451    #[test]
452    fn test_fast_path_reads_curr_slot_at_bar_0() {
453        let candles = daily_candles(&[100.0]);
454        let mut indicators = HashMap::new();
455        indicators.insert("htf_1wk_sma_3".to_string(), vec![Some(5.0)]);
456
457        let cond = htf(Interval::OneWeek, sma(3).above(0.0));
458        let ctx = StrategyContext {
459            candles: &candles,
460            index: 0,
461            position: None,
462            equity: 10_000.0,
463            indicators: &indicators,
464            extremes: None,
465            indicator_index: None,
466        };
467        assert!(cond.evaluate(&ctx));
468    }
469
470    #[test]
471    fn test_dynamic_fallback_price_refs_stay_on_base_bars() {
472        let mut prices = vec![99.0; 14];
473        prices.push(101.0);
474        let candles = daily_candles(&prices);
475        let indicators = HashMap::new();
476
477        let cond = htf(
478            Interval::OneWeek,
479            price().above(100.0).and(sma(1).above(0.0)),
480        );
481        let ctx = StrategyContext {
482            candles: &candles,
483            index: 14,
484            position: None,
485            equity: 10_000.0,
486            indicators: &indicators,
487            extremes: None,
488            indicator_index: None,
489        };
490        assert!(cond.evaluate(&ctx));
491    }
492}