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:
@@ -428,11 +428,18 @@ def _compute_scores_series(
|
||||
confirmed_div_low_idx.append(div_ptr)
|
||||
div_ptr += 1
|
||||
|
||||
# ATR% as of this candle — feeds both BOS's break-confirmation
|
||||
# buffer (fix uu) and detect_market_regime's adaptive "volatile"
|
||||
# threshold (fix kk) below, computed once and reused for both.
|
||||
atr_tail = _tail(atr_full, i, _SHORT_WINDOW)
|
||||
last_atr = atr_tail[-1] if atr_tail else None
|
||||
atr_pct = (last_atr / close_prices_full[i] * 100.0) if last_atr and close_prices_full[i] > 0 else None
|
||||
|
||||
# SMC (BOS/CHoCH/trend/order-blocks) from the causally-confirmed
|
||||
# swings above, instead of rescanning raw candles for pivots.
|
||||
recent_highs = confirmed_swing_highs[-3:]
|
||||
recent_lows = confirmed_swing_lows[-3:]
|
||||
bos = _detect_bos(recent_highs, recent_lows, [close_prices_full[i]])
|
||||
bos = _detect_bos(recent_highs, recent_lows, [close_prices_full[i]], atr_pct=atr_pct)
|
||||
choch = _detect_choch(
|
||||
confirmed_swing_highs[-5:], confirmed_swing_lows[-5:],
|
||||
close_prices_full[max(0, i - 19):i + 1],
|
||||
@@ -475,6 +482,9 @@ def _compute_scores_series(
|
||||
mtf_recent_highs = state["highs"][-3:]
|
||||
mtf_recent_lows = state["lows"][-3:]
|
||||
mtf_close = mtf["close_prices"][mtf_idx]
|
||||
# No per-MTF-timeframe ATR series is precomputed — falls back
|
||||
# to _detect_bos's fixed 0.3% buffer (fix uu only covers the
|
||||
# main timeframe's own BOS check above).
|
||||
mtf_bos = _detect_bos(mtf_recent_highs, mtf_recent_lows, [mtf_close])
|
||||
mtf_choch = _detect_choch(
|
||||
state["highs"][-5:], state["lows"][-5:],
|
||||
@@ -517,9 +527,6 @@ def _compute_scores_series(
|
||||
# in signal_scoring.py, applied in _simulate_from_scores once the
|
||||
# threshold combo decides a concrete signal_type).
|
||||
adx_data = _tail_dict(adx_full, i, _SHORT_WINDOW)
|
||||
atr_tail = _tail(atr_full, i, _SHORT_WINDOW)
|
||||
last_atr = atr_tail[-1] if atr_tail else None
|
||||
atr_pct = (last_atr / close_prices_full[i] * 100.0) if last_atr and close_prices_full[i] > 0 else None
|
||||
regime_start = max(0, i + 1 - _REGIME_WINDOW)
|
||||
atr_history_start = max(0, i + 1 - _ATR_HISTORY_WINDOW)
|
||||
market_regime = detect_market_regime(
|
||||
|
||||
@@ -484,9 +484,26 @@ async def get_indicators(
|
||||
else:
|
||||
computed["rsi_divergence"] = (None, None)
|
||||
|
||||
# Compute ATR% ahead of Market Structure/ADX below — both need it:
|
||||
# market_structure()'s BOS confirmation buffer adapts to it (fix
|
||||
# uu), and detect_market_regime's "volatile" cutoff adapts to its
|
||||
# full history (fix kk). Also keep the full history (not just the
|
||||
# latest value) for that second use.
|
||||
atr_pct_history: list[float | None] = []
|
||||
try:
|
||||
from app.services.indicator_service import atr as _calc_atr
|
||||
raw_atr = _calc_atr(candle_dicts, period=14)
|
||||
atr_pct_history = [
|
||||
(a / c * 100.0) if a and c and c > 0 else None
|
||||
for a, c in zip(raw_atr, close_prices)
|
||||
]
|
||||
atr_pct_val = atr_pct_history[-1] if atr_pct_history else None
|
||||
except Exception:
|
||||
atr_pct_val = None
|
||||
|
||||
# Add Market Structure (SMC)
|
||||
from app.services.indicator_service import market_structure
|
||||
computed["market_structure"] = market_structure(candle_dicts, pivot_lookback=3)
|
||||
computed["market_structure"] = market_structure(candle_dicts, pivot_lookback=3, atr_pct=atr_pct_val)
|
||||
|
||||
# Add MACD divergence detection
|
||||
macd_data = computed.get("macd", {})
|
||||
@@ -500,21 +517,6 @@ async def get_indicators(
|
||||
# Add ADX (Average Directional Index) + Market Regime
|
||||
computed["adx_data"] = adx(candle_dicts, period=14)
|
||||
atr_vals = computed.get("supertrend", {}).get("trend", None)
|
||||
# Compute ATR% for regime detection — also keep the full history
|
||||
# (not just the latest value) so detect_market_regime can judge
|
||||
# "volatile" against THIS symbol's own recent ATR% distribution
|
||||
# instead of one fixed cutoff shared by every symbol (fix kk).
|
||||
atr_pct_history: list[float | None] = []
|
||||
try:
|
||||
from app.services.indicator_service import atr as _calc_atr
|
||||
raw_atr = _calc_atr(candle_dicts, period=14)
|
||||
atr_pct_history = [
|
||||
(a / c * 100.0) if a and c and c > 0 else None
|
||||
for a, c in zip(raw_atr, close_prices)
|
||||
]
|
||||
atr_pct_val = atr_pct_history[-1] if atr_pct_history else None
|
||||
except Exception:
|
||||
atr_pct_val = None
|
||||
|
||||
# Extract high/low prices for regime detection
|
||||
high_prices = [c["high"] for c in candle_dicts]
|
||||
@@ -535,8 +537,9 @@ async def get_indicators(
|
||||
# Add MFI (Money Flow Index)
|
||||
computed["mfi_14"] = mfi(candle_dicts, period=14)
|
||||
|
||||
# Add FVG (Fair Value Gap)
|
||||
fvg_type, fvg_high, fvg_low = detect_fvg(candle_dicts, lookback=30)
|
||||
# Add FVG (Fair Value Gap) — atr_pct filters out gaps too small to
|
||||
# be meaningful relative to this symbol's own volatility (fix qq).
|
||||
fvg_type, fvg_high, fvg_low = detect_fvg(candle_dicts, lookback=30, atr_pct=atr_pct_val)
|
||||
computed["fvg"] = {"type": fvg_type, "gap_high": fvg_high, "gap_low": fvg_low}
|
||||
|
||||
# Add Candlestick Pattern Recognition
|
||||
|
||||
@@ -552,7 +552,13 @@ def volume_breakout(
|
||||
# Ichimoku Cloud
|
||||
# ======================================================================
|
||||
|
||||
def ichimoku(candles: list[dict]) -> dict[str, list]:
|
||||
def ichimoku(
|
||||
candles: list[dict],
|
||||
tenkan_period: int = 9,
|
||||
kijun_period: int = 26,
|
||||
senkou_b_period: int = 52,
|
||||
displacement: int = 26,
|
||||
) -> dict[str, list]:
|
||||
"""Ichimoku Cloud — comprehensive trend indicator.
|
||||
|
||||
Returns dict with keys:
|
||||
@@ -562,10 +568,19 @@ def ichimoku(candles: list[dict]) -> dict[str, list]:
|
||||
- ``senkou_b``: Leading Span B (cloud top/bottom)
|
||||
- ``chikou``: Lagging Span
|
||||
|
||||
All values aligned to candle index. First 51 entries are None.
|
||||
All values aligned to candle index. First `senkou_b_period - 1`
|
||||
entries are None.
|
||||
|
||||
(fix pp) The 9/26/52/26 defaults are Goichi Hosoda's original values,
|
||||
designed around Japan's historical 6-day trading week — there's no
|
||||
inherent reason they suit crypto's 24/7 markets, but changing them
|
||||
without empirical validation would just swap one unverified guess for
|
||||
another. Parameterized here (defaults unchanged) so a future walk-
|
||||
forward comparison against crypto-scaled values can be run without
|
||||
touching this function.
|
||||
"""
|
||||
n = len(candles)
|
||||
if n < 52:
|
||||
if n < senkou_b_period:
|
||||
return {"tenkan": [None] * n, "kijun": [None] * n,
|
||||
"senkou_a": [None] * n, "senkou_b": [None] * n,
|
||||
"chikou": [None] * n}
|
||||
@@ -580,28 +595,28 @@ def ichimoku(candles: list[dict]) -> dict[str, list]:
|
||||
senkou_b: list[Optional[float]] = [None] * n
|
||||
chikou: list[Optional[float]] = [None] * n
|
||||
|
||||
for i in range(8, n):
|
||||
tenkan[i] = (max(highs[i - 8 : i + 1]) + min(lows[i - 8 : i + 1])) / 2.0
|
||||
for i in range(tenkan_period - 1, n):
|
||||
tenkan[i] = (max(highs[i - tenkan_period + 1 : i + 1]) + min(lows[i - tenkan_period + 1 : i + 1])) / 2.0
|
||||
|
||||
for i in range(25, n):
|
||||
kijun[i] = (max(highs[i - 25 : i + 1]) + min(lows[i - 25 : i + 1])) / 2.0
|
||||
for i in range(kijun_period - 1, n):
|
||||
kijun[i] = (max(highs[i - kijun_period + 1 : i + 1]) + min(lows[i - kijun_period + 1 : i + 1])) / 2.0
|
||||
|
||||
# Senkou spans: shift forward by 26
|
||||
for i in range(25, n):
|
||||
# Senkou spans: shift forward by `displacement`
|
||||
for i in range(kijun_period - 1, n):
|
||||
if tenkan[i] is not None and kijun[i] is not None:
|
||||
sa = (tenkan[i] + kijun[i]) / 2.0
|
||||
if i + 26 < n:
|
||||
senkou_a[i + 26] = sa
|
||||
if i + displacement < n:
|
||||
senkou_a[i + displacement] = sa
|
||||
|
||||
for i in range(51, n):
|
||||
sb = (max(highs[i - 51 : i + 1]) + min(lows[i - 51 : i + 1])) / 2.0
|
||||
if i + 26 < n:
|
||||
senkou_b[i + 26] = sb
|
||||
for i in range(senkou_b_period - 1, n):
|
||||
sb = (max(highs[i - senkou_b_period + 1 : i + 1]) + min(lows[i - senkou_b_period + 1 : i + 1])) / 2.0
|
||||
if i + displacement < n:
|
||||
senkou_b[i + displacement] = sb
|
||||
|
||||
# Chikou: current close plotted 26 periods back
|
||||
# Chikou: current close plotted `displacement` periods back
|
||||
for i in range(n):
|
||||
if i + 26 < n:
|
||||
chikou[i] = closes[i + 26]
|
||||
if i + displacement < n:
|
||||
chikou[i] = closes[i + displacement]
|
||||
|
||||
return {
|
||||
"tenkan": tenkan,
|
||||
@@ -742,24 +757,45 @@ def _find_pivot_lows_levels(prices: list[float], left: int = 3, right: int = 3)
|
||||
return pivots
|
||||
|
||||
|
||||
# (fix uu) BOS used a fixed 0.3% break-confirmation buffer regardless of
|
||||
# the symbol's actual volatility — too wide for a calm major (BTC/ETH
|
||||
# 15m ATR% often well under 0.3%, missing real breaks) and too narrow for
|
||||
# a volatile altcoin (routinely whipsawing past 0.3% on noise). Scaling
|
||||
# the buffer by the symbol's own current ATR% adapts it the same way
|
||||
# fix (kk) adapted the "volatile" regime threshold.
|
||||
_BOS_ATR_BUFFER_MULT = 0.15
|
||||
_BOS_DEFAULT_BUFFER_PCT = 0.003 # fallback (0.3%) when ATR% isn't available
|
||||
|
||||
|
||||
def _detect_bos(
|
||||
swing_highs: list[Optional[float]],
|
||||
swing_lows: list[Optional[float]],
|
||||
prices: list[float],
|
||||
atr_pct: Optional[float] = None,
|
||||
) -> Optional[str]:
|
||||
"""Detect Break of Structure (BOS).
|
||||
|
||||
Bullish BOS: price breaks above the most recent swing high.
|
||||
Bearish BOS: price breaks below the most recent swing low.
|
||||
Returns \"BULLISH\", \"BEARISH\", or None.
|
||||
|
||||
`atr_pct` (current ATR as % of price), if given, scales the break-
|
||||
confirmation buffer to the symbol's own volatility instead of a fixed
|
||||
0.3% for every symbol — see _BOS_ATR_BUFFER_MULT above.
|
||||
"""
|
||||
recent_highs = [sh for sh in swing_highs if sh is not None][-3:]
|
||||
recent_lows = [sl for sl in swing_lows if sl is not None][-3:]
|
||||
current_price = prices[-1]
|
||||
|
||||
if recent_highs and current_price > max(recent_highs) * 1.003:
|
||||
buffer_pct = (
|
||||
(atr_pct / 100.0) * _BOS_ATR_BUFFER_MULT
|
||||
if atr_pct is not None and atr_pct > 0
|
||||
else _BOS_DEFAULT_BUFFER_PCT
|
||||
)
|
||||
|
||||
if recent_highs and current_price > max(recent_highs) * (1 + buffer_pct):
|
||||
return "BULLISH"
|
||||
if recent_lows and current_price < min(recent_lows) * 0.997:
|
||||
if recent_lows and current_price < min(recent_lows) * (1 - buffer_pct):
|
||||
return "BEARISH"
|
||||
return None
|
||||
|
||||
@@ -854,6 +890,7 @@ def _detect_order_blocks(
|
||||
def market_structure(
|
||||
candles: list[dict],
|
||||
pivot_lookback: int = 3,
|
||||
atr_pct: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""Comprehensive Market Structure analysis (SMC).
|
||||
|
||||
@@ -866,6 +903,10 @@ def market_structure(
|
||||
- ``trend``: \"BULLISH\", \"BEARISH\", or \"NEUTRAL\"
|
||||
- ``last_swing_high``: most recent swing high price
|
||||
- ``last_swing_low``: most recent swing low price
|
||||
|
||||
`atr_pct` (current ATR as % of price), if given, adapts BOS's break-
|
||||
confirmation buffer to this symbol's own volatility — see
|
||||
`_detect_bos`/_BOS_ATR_BUFFER_MULT.
|
||||
"""
|
||||
n = len(candles)
|
||||
if n < 20:
|
||||
@@ -882,7 +923,7 @@ def market_structure(
|
||||
swing_highs = _find_pivot_highs_levels(prices, pivot_lookback, pivot_lookback)
|
||||
swing_lows = _find_pivot_lows_levels(prices, pivot_lookback, pivot_lookback)
|
||||
|
||||
bos = _detect_bos(swing_highs, swing_lows, prices)
|
||||
bos = _detect_bos(swing_highs, swing_lows, prices, atr_pct=atr_pct)
|
||||
choch = _detect_choch(swing_highs, swing_lows, prices)
|
||||
obs = _detect_order_blocks(candles, lookback=min(40, n))
|
||||
|
||||
@@ -1371,9 +1412,20 @@ def mfi(candles: list[dict], period: int = 14) -> list[Optional[float]]:
|
||||
# FVG — Fair Value Gap (SMC inefficiency)
|
||||
# ======================================================================
|
||||
|
||||
# (fix qq) Any gap, however tiny, used to count as a valid FVG — on a 15m
|
||||
# chart that's noise: a gap worth a few ticks carries none of the
|
||||
# "unfilled institutional order" significance the concept is meant to
|
||||
# capture, and votes in signal_scoring.py just as strongly as a
|
||||
# meaningful gap. Requiring the gap to be at least this fraction of the
|
||||
# symbol's own current ATR filters that out; falls back to no size filter
|
||||
# (previous behavior) when ATR% isn't supplied.
|
||||
_FVG_MIN_GAP_ATR_MULT = 0.1
|
||||
|
||||
|
||||
def detect_fvg(
|
||||
candles: list[dict],
|
||||
lookback: int = 30,
|
||||
atr_pct: Optional[float] = None,
|
||||
) -> tuple[Optional[str], Optional[float], Optional[float]]:
|
||||
"""Detect the most recent Fair Value Gap (imbalance / inefficiency).
|
||||
|
||||
@@ -1382,6 +1434,11 @@ def detect_fvg(
|
||||
A Bearish FVG occurs when the high of candle i is lower than the
|
||||
low of candle i+2 (gap down — unfilled sell orders).
|
||||
|
||||
`atr_pct` (current ATR as % of price), if given, rejects gaps smaller
|
||||
than `_FVG_MIN_GAP_ATR_MULT` of it — see the module-level comment
|
||||
above (fix qq). Without it, any nonzero gap still counts, same as
|
||||
before this parameter existed.
|
||||
|
||||
Returns (fvg_type, gap_high, gap_low):
|
||||
- fvg_type: "BULLISH", "BEARISH", or None
|
||||
- gap_high: upper bound of the gap
|
||||
@@ -1406,17 +1463,28 @@ def detect_fvg(
|
||||
if c0_low > c2_high:
|
||||
gap_high = c0_low
|
||||
gap_low = c2_high
|
||||
if _fvg_gap_too_small(gap_high, gap_low, atr_pct):
|
||||
continue
|
||||
return "BULLISH", gap_high, gap_low
|
||||
|
||||
# Bearish FVG: C0 high < C2 low → gap down
|
||||
if c0_high < c2_low:
|
||||
gap_high = c2_low
|
||||
gap_low = c0_high
|
||||
if _fvg_gap_too_small(gap_high, gap_low, atr_pct):
|
||||
continue
|
||||
return "BEARISH", gap_high, gap_low
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _fvg_gap_too_small(gap_high: float, gap_low: float, atr_pct: Optional[float]) -> bool:
|
||||
if atr_pct is None or atr_pct <= 0 or gap_low <= 0:
|
||||
return False
|
||||
gap_pct = (gap_high - gap_low) / gap_low * 100.0
|
||||
return gap_pct < atr_pct * _FVG_MIN_GAP_ATR_MULT
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Candlestick Pattern Recognition (single vote from 30+ patterns)
|
||||
# ======================================================================
|
||||
|
||||
Reference in New Issue
Block a user