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}