fix: order block displacement/volume confirmation, Tweezer Bottom epsilon inconsistency

- indicator_service.py: _detect_order_blocks() now requires the
  confirming candles to actually displace price past the OB candle's own
  high/low (the "imbalance" a real SMC order block is supposed to leave
  behind) instead of accepting any 2-candle same-direction sequence, even
  one that stays entirely inside the OB candle's range. strength (1-3)
  now reflects real displacement/volume confirmation instead of always 1.
- indicator_service.py: removed a stray "+ 0.001" from Tweezer Bottom's
  zero-division guard that Tweezer Top's otherwise-identical formula
  didn't have -- both already guard via an explicit > 0 check, so the
  epsilon just made the two directions inconsistent for no reason.

237 backend tests pass (+5). This closes out the full 13-algorithm audit
started in v10 -- only (tt) parameter re-optimization (needs empirical
walk-forward runs, not a code change) and (yy) multi-position portfolio
backtesting (needs a new architecture) remain, both flagged as separate
larger investment decisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 21:39:17 +07:00
parent 842631744c
commit 0a93af10a9
3 changed files with 203 additions and 9 deletions
+82
View File
@@ -34,6 +34,8 @@ from app.services.indicator_service import (
_BOS_ATR_BUFFER_MULT,
detect_fvg,
ichimoku,
detect_candlestick_patterns,
_detect_order_blocks,
)
@@ -41,6 +43,10 @@ def candle(high, low, close, volume=100.0):
return {"high": high, "low": low, "close": close, "volume": volume}
def ohlc(open, high, low, close, volume=100.0):
return {"open": open, "high": high, "low": low, "close": close, "volume": volume}
class TestSma:
def test_leading_entries_are_none(self):
result = sma([1, 2, 3, 4, 5], period=3)
@@ -600,3 +606,79 @@ class TestIchimokuConfigurablePeriods:
i = 70
assert custom_result["tenkan"][i] != pytest.approx(default_result["tenkan"][i])
assert custom_result["kijun"][i] != pytest.approx(default_result["kijun"][i])
class TestCandlestickTweezerSymmetry:
"""Regression test for fix (ww): Tweezer Bottom's zero-division guard
had a stray `+ 0.001` epsilon that Tweezer Top's otherwise-identical
formula didn't have — both already guard against a zero denominator
via an explicit `> 0` check, so the epsilon was dead weight that made
the two directions inconsistent for no reason. Locks in that a small
(within-tolerance) gap is detected symmetrically in both directions.
"""
def test_tweezer_bottom_detects_small_matching_low_gap(self):
c2 = ohlc(100, 101, 99, 100)
c1 = ohlc(105, 106, 95.0, 96) # bearish, low=95.0
c0 = ohlc(96, 97, 95.02, 96.5) # bullish, low=95.02 -> gap ~0.021%
assert detect_candlestick_patterns([c2, c1, c0]) == pytest.approx(1.5)
def test_tweezer_top_detects_small_matching_high_gap(self):
c2 = ohlc(100, 101, 99, 100)
c1 = ohlc(95, 106.0, 94, 105) # bullish, high=106.0
c0 = ohlc(105, 105.98, 94, 95) # bearish, high=105.98 -> gap ~0.019%
assert detect_candlestick_patterns([c2, c1, c0]) == pytest.approx(-1.5)
class TestOrderBlockDisplacementAndVolume:
"""Regression tests for fix (vv): any 2-candle same-direction sequence
after the OB candle used to count as a valid order block, even if
price never actually cleared that candle's own high/low — the
"imbalance" a real SMC order block is supposed to leave behind wasn't
checked at all, and `strength` was hardcoded to 1. Now requires actual
displacement past the OB candle's range, and boosts (never gates on)
strength when volume and/or displacement are unusually large.
"""
def _candles_with_ob(self, confirm_high=None, confirm_low=None, volume_spike=False):
# Bullish OB candidate: bearish OB candle at some index, followed
# by 2 bullish confirming candles.
base = [ohlc(100, 101, 99, 100, volume=100) for _ in range(20)]
ob_candle = candle(105, 95, 96, volume=100) # bearish (open > close conceptually via high/low only used)
# candle() here only carries high/low/close/volume (no open) since
# _detect_order_blocks reads curr["open"]/["close"] — build OHLC
# explicitly instead for the 3 candles that matter.
ob = ohlc(104, 105, 95, 96, volume=100) # bearish: close(96) < open(104)
nxt1_close = confirm_high - 1 if confirm_high else 106
nxt1 = ohlc(97, nxt1_close + 1, 96, nxt1_close, volume=200 if volume_spike else 100)
nxt2_close = confirm_high if confirm_high else 107
nxt2 = ohlc(nxt1_close, nxt2_close + 1, nxt1_close - 1, nxt2_close, volume=200 if volume_spike else 100)
return base + [ob, nxt1, nxt2]
def test_bullish_ob_requires_displacement_past_ob_high(self):
# Confirming candles close ABOVE the OB candle's own high (105) -> valid.
candles = self._candles_with_ob(confirm_high=110)
blocks = _detect_order_blocks(candles, lookback=20)
assert any(b["type"] == "BULLISH" for b in blocks)
def test_bullish_ob_rejected_when_confirmation_stays_inside_ob_range(self):
# Confirming candles close ABOVE their own open (still "bullish"
# candles) but never clear the OB candle's high (105) — the old
# code accepted this; the fix requires real displacement.
base = [ohlc(100, 101, 99, 100, volume=100) for _ in range(20)]
ob = ohlc(104, 105, 95, 96, volume=100)
nxt1 = ohlc(96.2, 96.8, 96.0, 96.6, volume=100) # bullish but tiny, inside OB range
nxt2 = ohlc(96.6, 97.5, 96.5, 97.0, volume=100) # bullish but still under OB high (105)
candles = base + [ob, nxt1, nxt2]
blocks = _detect_order_blocks(candles, lookback=20)
assert not any(b["type"] == "BULLISH" for b in blocks)
def test_strength_increases_with_volume_confirmation(self):
candles_no_spike = self._candles_with_ob(confirm_high=110, volume_spike=False)
candles_with_spike = self._candles_with_ob(confirm_high=110, volume_spike=True)
blocks_no_spike = [b for b in _detect_order_blocks(candles_no_spike, lookback=20) if b["type"] == "BULLISH"]
blocks_with_spike = [b for b in _detect_order_blocks(candles_with_spike, lookback=20) if b["type"] == "BULLISH"]
assert blocks_no_spike and blocks_with_spike
assert blocks_with_spike[-1]["strength"] > blocks_no_spike[-1]["strength"]