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}