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]`.
24//!
25//! # Fallback (dynamic resampling)
26//!
27//! If the pre-computed arrays are not found in `ctx.indicators` (e.g. a raw
28//! [`Strategy`] implementation that doesn't forward `htf_requirements()`), the
29//! condition falls back to dynamic resampling — O(n) per bar, O(n²) total.
30//! A `tracing::warn!` is emitted once per evaluation so the caller can diagnose
31//! the performance issue.
32//!
33//! # Example
34//!
35//! ```ignore
36//! use finance_query::backtesting::{StrategyBuilder, BacktestConfig, BacktestEngine};
37//! use finance_query::backtesting::refs::*;
38//! use finance_query::Interval;
39//!
40//! // Enter only when daily EMA10 crosses EMA30 AND weekly price > SMA20
41//! let strategy = StrategyBuilder::new("MTF Confirmation")
42//!     .entry(
43//!         ema(10).crosses_above_ref(ema(30))
44//!             .and(htf(Interval::OneWeek, price().above_ref(sma(20))))
45//!     )
46//!     .exit(ema(10).crosses_below_ref(ema(30)))
47//!     .build();
48//! ```
49//!
50//! For a Tokyo Stock Exchange strategy:
51//!
52//! ```ignore
53//! use finance_query::backtesting::refs::*;
54//! use finance_query::{Interval, Region};
55//!
56//! let weekly_trend = htf_region(Interval::OneWeek, Region::Japan, price().above_ref(sma(20)));
57//! ```
58//!
59//! [`StrategyBuilder`]: crate::backtesting::strategy::StrategyBuilder
60//! [`Strategy`]: crate::backtesting::strategy::Strategy
61
62use std::collections::HashMap;
63
64use crate::backtesting::condition::{Condition, HtfIndicatorSpec};
65use crate::backtesting::engine::compute_for_candles;
66use crate::backtesting::resample::resample;
67use crate::backtesting::strategy::StrategyContext;
68use crate::constants::{Interval, Region};
69use crate::indicators::Indicator;
70
71/// A condition that evaluates its inner condition on a resampled HTF candle series.
72///
73/// Created by [`htf()`] (UTC-aligned) or [`htf_region()`] (exchange-local calendar).
74#[derive(Clone)]
75pub struct HtfCondition<C: Condition> {
76    interval: Interval,
77    inner: C,
78    /// UTC offset of the exchange. Shifts bucket boundaries so weekly/monthly
79    /// periods align with the exchange's local calendar rather than UTC midnight.
80    utc_offset_secs: i64,
81}
82
83impl<C: Condition> Condition for HtfCondition<C> {
84    fn evaluate(&self, ctx: &StrategyContext) -> bool {
85        let required = self.inner.required_indicators();
86
87        // ── Fast path: use pre-computed stretched arrays from the engine ──────
88        // The engine stores stretched HTF values in ctx.indicators under keys of
89        // the form "htf_{interval}_{base_key}" (e.g. "htf_1wk_sma_20").
90        //
91        // We build a tiny 2-element indicators map [prev, curr] so that the inner
92        // condition's crossover helpers (indicator_prev / crossed_above etc.) work
93        // correctly, then evaluate with index=1.
94        if !required.is_empty() {
95            let interval_str = self.interval.as_str();
96            let mut mini_indicators: HashMap<String, Vec<Option<f64>>> =
97                HashMap::with_capacity(required.len());
98            let mut all_found = true;
99
100            for (base_key, _) in &required {
101                let htf_key = format!("htf_{}_{}", interval_str, base_key);
102                if let Some(stretched) = ctx.indicators.get(&htf_key) {
103                    let curr = stretched.get(ctx.index).copied().flatten();
104                    let prev = ctx
105                        .index
106                        .checked_sub(1)
107                        .and_then(|pi| stretched.get(pi).copied().flatten());
108                    // index=1 → curr; index=0 → prev (via indicator_prev)
109                    mini_indicators.insert(base_key.clone(), vec![prev, curr]);
110                } else {
111                    all_found = false;
112                    break;
113                }
114            }
115
116            if all_found {
117                // Use a 2-candle slice [prev_bar, current_bar] so that
118                // current_candle() (index=1) returns the correct base bar.
119                let start = ctx.index.saturating_sub(1);
120                let htf_ctx = StrategyContext {
121                    candles: &ctx.candles[start..=ctx.index],
122                    index: ctx.index - start, // 1 unless at bar 0
123                    position: ctx.position,
124                    equity: ctx.equity,
125                    indicators: &mini_indicators,
126                };
127                return self.inner.evaluate(&htf_ctx);
128            }
129        } else {
130            // Pure price condition — no HTF indicators needed.
131            // Evaluate directly so price() reads from the current base bar.
132            return self.inner.evaluate(ctx);
133        }
134
135        // ── Fallback: dynamic resampling (O(n) per bar → O(n²) total) ────────
136        // Reached only when the strategy does not implement htf_requirements()
137        // (e.g. a raw Strategy impl). StrategyBuilder-based strategies never hit
138        // this path because the engine pre-computes the stretched arrays.
139        tracing::warn!(
140            interval = %self.interval,
141            "HtfCondition falling back to O(n²) dynamic resampling — \
142             implement Strategy::htf_requirements() or use StrategyBuilder \
143             to enable O(1) pre-computed HTF lookups"
144        );
145        self.evaluate_dynamic(ctx)
146    }
147
148    fn required_indicators(&self) -> Vec<(String, Indicator)> {
149        // HtfCondition resolves its own indicators on resampled data.
150        // The main engine must NOT pre-compute these on the base-TF candles.
151        vec![]
152    }
153
154    fn htf_requirements(&self) -> Vec<HtfIndicatorSpec> {
155        let interval_str = self.interval.as_str();
156        self.inner
157            .required_indicators()
158            .into_iter()
159            .map(|(base_key, indicator)| HtfIndicatorSpec {
160                interval: self.interval,
161                htf_key: format!("htf_{}_{}", interval_str, base_key),
162                base_key,
163                indicator,
164                utc_offset_secs: self.utc_offset_secs,
165            })
166            .collect()
167    }
168
169    fn description(&self) -> String {
170        format!("htf({}, {})", self.interval, self.inner.description())
171    }
172}
173
174impl<C: Condition> HtfCondition<C> {
175    /// Dynamic resampling fallback used when pre-computed data is unavailable.
176    ///
177    /// This is O(n) per bar (O(n²) overall). It finds the most recently
178    /// *completed* HTF bar (timestamp < current bar) to avoid look-ahead bias.
179    fn evaluate_dynamic(&self, ctx: &StrategyContext) -> bool {
180        let htf_candles = resample(ctx.candles, self.interval, self.utc_offset_secs);
181
182        // Find the most recently completed HTF bar — timestamp strictly less than
183        // the current base bar's timestamp. In the dynamic path we use `<` (not
184        // `<=`) to conservatively exclude the in-progress period: we only have
185        // candles up to ctx.index, so the last HTF bar may be partial.
186        let current_ts = ctx.current_candle().timestamp;
187        let htf_idx = match htf_candles.iter().rposition(|c| c.timestamp < current_ts) {
188            Some(i) => i,
189            None => return false, // No completed HTF bar yet
190        };
191
192        let required = self.inner.required_indicators();
193        let htf_indicators = if required.is_empty() {
194            HashMap::new()
195        } else {
196            match compute_for_candles(&htf_candles, required) {
197                Ok(map) => map,
198                Err(e) => {
199                    tracing::warn!("HTF indicator computation failed: {}", e);
200                    return false;
201                }
202            }
203        };
204
205        let htf_ctx = StrategyContext {
206            candles: &htf_candles,
207            index: htf_idx,
208            position: ctx.position,
209            equity: ctx.equity,
210            indicators: &htf_indicators,
211        };
212
213        self.inner.evaluate(&htf_ctx)
214    }
215}
216
217/// Wrap a condition to be evaluated on a higher-timeframe candle series.
218///
219/// Bucket boundaries are UTC-aligned (offset = 0). For non-UTC exchanges use
220/// [`htf_region()`] instead.
221///
222/// # Arguments
223///
224/// * `interval` – Target higher timeframe (e.g. `Interval::OneWeek`)
225/// * `cond` – Any condition to evaluate on the HTF candles
226///
227/// # Example
228///
229/// ```ignore
230/// use finance_query::backtesting::refs::*;
231/// use finance_query::Interval;
232///
233/// // Entry only when weekly price is above its 20-bar SMA
234/// let weekly_uptrend = htf(Interval::OneWeek, price().above_ref(sma(20)));
235/// let entry = ema(10).crosses_above_ref(ema(30)).and(weekly_uptrend);
236/// ```
237pub fn htf<C: Condition>(interval: Interval, cond: C) -> HtfCondition<C> {
238    HtfCondition {
239        interval,
240        inner: cond,
241        utc_offset_secs: 0,
242    }
243}
244
245/// Wrap a condition to be evaluated on a higher-timeframe candle series,
246/// with bucket boundaries aligned to the exchange's local calendar.
247///
248/// Weekly and monthly boundaries are shifted by `region.utc_offset_secs()` so
249/// that, for example, a Tokyo-listed stock's "Monday" starts at the correct
250/// local midnight rather than UTC midnight.
251///
252/// # Arguments
253///
254/// * `interval` – Target higher timeframe (e.g. `Interval::OneWeek`)
255/// * `region`   – Exchange region used to derive the UTC offset
256/// * `cond`     – Any condition to evaluate on the HTF candles
257///
258/// # Example
259///
260/// ```ignore
261/// use finance_query::backtesting::refs::*;
262/// use finance_query::{Interval, Region};
263///
264/// let weekly_trend = htf_region(Interval::OneWeek, Region::Japan, price().above_ref(sma(20)));
265/// ```
266pub fn htf_region<C: Condition>(interval: Interval, region: Region, cond: C) -> HtfCondition<C> {
267    HtfCondition {
268        interval,
269        inner: cond,
270        utc_offset_secs: region.utc_offset_secs(),
271    }
272}