feat: ATR-adaptive BOS buffer, FVG gap-size filter, configurable Ichimoku periods

- indicator_service.py: _detect_bos()/market_structure() now scale the
  break-confirmation buffer by the symbol's own current ATR% instead of a
  fixed 0.3% for every symbol; falls back to the fixed value when ATR%
  isn't supplied.
- indicator_service.py: detect_fvg() rejects gaps smaller than 10% of
  current ATR% when atr_pct is given, filtering noise-sized gaps that
  carried no real "unfilled order" significance on low timeframes.
- candle_service.py: computes ATR% earlier so it can feed both
  market_structure() and detect_fvg(), not just detect_market_regime();
  backtest_engine.py reuses the same per-candle ATR% for BOS instead of
  computing it twice.
- indicator_service.py: ichimoku() takes tenkan/kijun/senkou_b_period and
  displacement as parameters (defaults unchanged at 9/26/52/26) so a
  future walk-forward comparison against crypto-scaled periods doesn't
  require editing the function — the classic Japanese-calendar defaults
  aren't changed here since that needs empirical validation, not a guess.

232 backend tests pass (+13).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 21:02:59 +07:00
parent 2399d0cb0c
commit 842631744c
5 changed files with 407 additions and 43 deletions
+198
View File
@@ -29,6 +29,11 @@ from app.services.indicator_service import (
_DEFAULT_VOLATILE_THRESHOLD_PCT,
_MIN_VOLATILE_THRESHOLD_PCT,
_MAX_VOLATILE_THRESHOLD_PCT,
_detect_bos,
_BOS_DEFAULT_BUFFER_PCT,
_BOS_ATR_BUFFER_MULT,
detect_fvg,
ichimoku,
)
@@ -402,3 +407,196 @@ class TestPivotCausalConsistency:
confirmable = len(candles) - 3
assert ms_after["swing_highs"][:confirmable] == ms_before["swing_highs"][:confirmable]
assert ms_after["swing_lows"][:confirmable] == ms_before["swing_lows"][:confirmable]
class TestBosAtrBuffer:
"""Regression tests for fix (uu): BOS used a fixed 0.3% break-
confirmation buffer for every symbol — too wide for a calm major
(missing real breaks) and too narrow for a volatile altcoin (false
breaks on noise). The buffer now scales with the symbol's own current
ATR%, falling back to the original fixed 0.3% when ATR% isn't given.
"""
def test_no_atr_pct_falls_back_to_fixed_buffer(self):
swing_highs = [None, None, 100.0]
swing_lows = [None, None, 90.0]
# Just barely above the old fixed 0.3% buffer -> BULLISH.
just_over = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT) + 0.01
just_under = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT) - 0.01
assert _detect_bos(swing_highs, swing_lows, [just_over]) == "BULLISH"
assert _detect_bos(swing_highs, swing_lows, [just_under]) is None
def test_high_atr_pct_widens_the_buffer(self):
"""A break that would confirm under the fixed 0.3% buffer must NOT
confirm yet when the symbol's own ATR% implies a much wider
'normal' move (avoiding a false break on a volatile symbol)."""
swing_highs = [None, None, 100.0]
swing_lows = [None, None, 90.0]
price_just_over_fixed_buffer = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT) + 0.01
assert _detect_bos(swing_highs, swing_lows, [price_just_over_fixed_buffer]) == "BULLISH"
# atr_pct=10 -> buffer = 10/100 * _BOS_ATR_BUFFER_MULT = 1.5%, far
# wider than the fixed 0.3% — the same price no longer confirms.
assert _detect_bos(swing_highs, swing_lows, [price_just_over_fixed_buffer], atr_pct=10.0) is None
def test_low_atr_pct_narrows_the_buffer(self):
"""A calm symbol's own ATR% implies a narrower 'normal' move than
the fixed 0.3% — a break should confirm sooner (closer to the
actual swing level) than the fixed buffer would allow."""
swing_highs = [None, None, 100.0]
swing_lows = [None, None, 90.0]
# atr_pct=0.2 -> buffer = 0.2/100 * 0.15 = 0.03%, much tighter
# than the fixed 0.3%.
price_within_fixed_buffer_but_past_atr_buffer = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT / 2)
assert _detect_bos(swing_highs, swing_lows, [price_within_fixed_buffer_but_past_atr_buffer]) is None
assert _detect_bos(
swing_highs, swing_lows, [price_within_fixed_buffer_but_past_atr_buffer], atr_pct=0.2,
) == "BULLISH"
def test_bearish_break_also_scales_with_atr(self):
swing_highs = [None, None, 100.0]
swing_lows = [None, None, 90.0]
price_just_under_fixed_buffer = 90.0 * (1 - _BOS_DEFAULT_BUFFER_PCT) - 0.01
assert _detect_bos(swing_highs, swing_lows, [price_just_under_fixed_buffer]) == "BEARISH"
assert _detect_bos(swing_highs, swing_lows, [price_just_under_fixed_buffer], atr_pct=10.0) is None
def test_market_structure_threads_atr_pct_into_bos(self, monkeypatch):
"""market_structure() must actually pass its atr_pct argument
through to _detect_bos rather than silently ignoring it."""
import app.services.indicator_service as indicator_service_module
received = {}
def spy_detect_bos(swing_highs, swing_lows, prices, atr_pct=None):
received["atr_pct"] = atr_pct
return None
monkeypatch.setattr(indicator_service_module, "_detect_bos", spy_detect_bos)
prices = [100.0 + math.sin(i / 3.0) * 10 + (i % 5) for i in range(30)]
candles = [candle(p + 0.5, p - 0.5, p) for p in prices]
market_structure(candles, pivot_lookback=3, atr_pct=7.5)
assert received["atr_pct"] == 7.5
class TestDetectFvgAtrFilter:
"""Regression tests for fix (qq): any nonzero Fair Value Gap used to
count as valid regardless of size — noisy on a 15m chart, where a
gap worth a few ticks carries none of the "unfilled institutional
order" significance the concept is meant to capture. Gaps smaller
than `_FVG_MIN_GAP_ATR_MULT` x ATR% are now rejected when `atr_pct`
is supplied; without it, behavior is unchanged from before this fix.
"""
def _bullish_gap_candles(self, c0_low: float, c2_high: float):
# detect_fvg only reads high/low of c0 and c2 — the middle candle's
# values are never inspected, only its presence in the sequence.
filler = candle(1000, 1, 500) # engulfs everything -> can never itself form a gap
c0 = candle(c0_low + 1, c0_low, c0_low + 0.5)
c1 = candle(c0_low - 0.1, c2_high + 0.1, c0_low - 0.5)
c2 = candle(c2_high, c2_high - 1, c2_high - 0.5)
return [filler, filler, c0, c1, c2]
def test_gap_detected_without_atr_pct_regardless_of_size(self):
candles = self._bullish_gap_candles(c0_low=100.1, c2_high=100.0)
fvg_type, gap_high, gap_low = detect_fvg(candles)
assert fvg_type == "BULLISH"
assert gap_high == pytest.approx(100.1)
assert gap_low == pytest.approx(100.0)
def test_tiny_gap_rejected_when_atr_pct_given(self):
# gap = (100.1-100.0)/100.0 = 0.1% of price; atr_pct=5 -> minimum
# required = 5 * _FVG_MIN_GAP_ATR_MULT(0.1) = 0.5% -> too small.
candles = self._bullish_gap_candles(c0_low=100.1, c2_high=100.0)
fvg_type, gap_high, gap_low = detect_fvg(candles, atr_pct=5.0)
assert fvg_type is None
assert gap_high is None and gap_low is None
def test_large_enough_gap_accepted_with_atr_pct(self):
# gap = (102.0-100.0)/100.0 = 2% of price; atr_pct=5 -> minimum
# required 0.5% -> comfortably passes.
candles = self._bullish_gap_candles(c0_low=102.0, c2_high=100.0)
fvg_type, gap_high, gap_low = detect_fvg(candles, atr_pct=5.0)
assert fvg_type == "BULLISH"
assert gap_high == pytest.approx(102.0)
assert gap_low == pytest.approx(100.0)
def test_bearish_gap_also_filtered_by_atr(self):
# Bearish: C0 high < C2 low -> gap down.
filler = candle(1000, 1, 500) # engulfs everything -> can never itself form a gap
c0 = candle(100.0, 99.0, 99.5) # c0_high = 100.0
c1 = candle(101.0, 100.5, 100.8)
c2 = candle(101.0, 100.1, 100.5) # c2_low = 100.1 -> gap 0.1% of 100.1
tiny_gap_candles = [filler, filler, c0, c1, c2]
fvg_type, _, _ = detect_fvg(tiny_gap_candles, atr_pct=5.0)
assert fvg_type is None
fvg_type_no_atr, gap_high, gap_low = detect_fvg(tiny_gap_candles)
assert fvg_type_no_atr == "BEARISH"
assert gap_high == pytest.approx(100.1)
assert gap_low == pytest.approx(100.0)
class TestIchimokuConfigurablePeriods:
"""Regression tests for fix (pp): Ichimoku's tenkan/kijun/senkou_b
periods and displacement are now parameters (defaulting to the
original 9/26/52/26) instead of hardcoded, so a future walk-forward
comparison against crypto-scaled periods can be run without editing
this function. The defaults themselves are intentionally unchanged —
picking new ones needs empirical validation, not a guess.
"""
def _flat_range_candles(self, n: int):
# high == low == close for every candle -> for a strictly
# increasing close series, max(highs) over any trailing window is
# just the window's last close, and min(lows) is the window's
# first close, making the expected tenkan/kijun/senkou_b values
# trivial to hand-verify.
closes = [float(i) for i in range(1, n + 1)]
return closes, [candle(c, c, c) for c in closes]
def test_default_periods_match_original_9_26_52_26(self):
closes, candles = self._flat_range_candles(100)
result = ichimoku(candles)
i = 60 # well past every warmup period
assert result["tenkan"][i] == pytest.approx((closes[i] + closes[i - 8]) / 2.0)
assert result["kijun"][i] == pytest.approx((closes[i] + closes[i - 25]) / 2.0)
# senkou_a/senkou_b are shifted forward by displacement=26.
expected_senkou_a = (result["tenkan"][i] + result["kijun"][i]) / 2.0
assert result["senkou_a"][i + 26] == pytest.approx(expected_senkou_a)
assert result["senkou_b"][i + 26] == pytest.approx((closes[i] + closes[i - 51]) / 2.0)
assert result["chikou"][i] == pytest.approx(closes[i + 26])
def test_insufficient_data_for_default_periods_returns_all_none(self):
_closes, candles = self._flat_range_candles(51) # senkou_b_period=52, one short
result = ichimoku(candles)
assert all(v is None for v in result["tenkan"])
assert all(v is None for v in result["senkou_b"])
def test_custom_shorter_periods_need_less_warmup_and_change_values(self):
closes, candles = self._flat_range_candles(30)
default_needs_more_data = ichimoku(candles) # n=30 < senkou_b_period=52
assert all(v is None for v in default_needs_more_data["tenkan"])
custom = ichimoku(candles, tenkan_period=3, kijun_period=5, senkou_b_period=10, displacement=4)
i = 15
assert custom["tenkan"][i] == pytest.approx((closes[i] + closes[i - 2]) / 2.0)
assert custom["kijun"][i] == pytest.approx((closes[i] + closes[i - 4]) / 2.0)
assert custom["senkou_b"][i + 4] == pytest.approx((closes[i] + closes[i - 9]) / 2.0)
assert custom["chikou"][i] == pytest.approx(closes[i + 4])
def test_custom_periods_produce_different_cloud_than_defaults(self):
_closes, candles = self._flat_range_candles(100)
default_result = ichimoku(candles)
custom_result = ichimoku(candles, tenkan_period=5, kijun_period=13, senkou_b_period=26, displacement=13)
i = 70
assert custom_result["tenkan"][i] != pytest.approx(default_result["tenkan"][i])
assert custom_result["kijun"][i] != pytest.approx(default_result["kijun"][i])