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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user