Wire ADX/regime detection into shared signal filter, unify the two regime systems

The market-regime filter (suppress directional signals in choppy/sideways
conditions, downgrade STRONG signals in volatile ones) already existed,
but only as a manual post-classification check inside signal_service.py's
live-trading path. Backtest and walk-forward called signal_scoring.py's
classifier directly, bypassing it entirely — so backtest results
systematically overestimated trade frequency and risk exposure relative
to what live trading actually does.

Factored the filter into a shared _apply_regime_filter() in
signal_scoring.py and added an optional market_regime param to
_classify_signal_combined() (default None preserves existing behavior for
every other caller, e.g. MTF sub-votes). backtest_engine.py now precomputes
adx()/atr() alongside the other rolling-window indicators and computes
detect_market_regime() per candle with the same bounded-window formula
live trading uses (candle_service.py), applying the filter once a
threshold combo resolves a concrete signal type. signal_service.py now
passes its regime into the shared classifier instead of duplicating the
check.

Also found and fixed a second, independent regime system: close_stale_trades()
(SL/TP sizing for open trades) computed its own cruder ATR-percentile-only
regime bucketing, which could disagree with the ADX+BB+Choppiness+
Efficiency-Ratio classification used for entry filtering. It now reads
market_regime from get_indicators() — the same TTL-cached function the
live signal-generation loop already populates — so entry filtering and
exit sizing agree on what "volatile" or "choppy" means for a given symbol.

14 new tests (unit tests for _apply_regime_filter, backtest integration
tests proving the filter suppresses/allows trades by regime, and a
signal_service test confirming close_stale_trades sources its regime from
the shared cache). 170 backend tests passing, frontend build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 11:25:43 +07:00
parent 1c022264f5
commit 0e77c055ba
7 changed files with 381 additions and 43 deletions
+58
View File
@@ -301,3 +301,61 @@ def test_order_block_detection_uses_bounded_window_regardless_of_dataset_size(mo
assert len(seen_lengths) > 0
assert max(seen_lengths) <= backtest_engine._ORDER_BLOCK_LOOKBACK + 3
def test_score_series_includes_a_valid_market_regime_label():
base = datetime.now(timezone.utc) - timedelta(hours=200)
prices = [100 + 10 * math.sin(i / 9) for i in range(200)]
candles = _build_candle_series(base, prices)
precomputed = backtest_engine._precompute_indicators(candles, "1h")
scores = backtest_engine._compute_scores_series(candles, precomputed)
valid_regimes = {"trending", "sideways", "volatile", "breakout", "squeeze", "choppy", "neutral"}
assert len(scores) > 0
for entry in scores:
assert entry["market_regime"] in valid_regimes
async def test_regime_filter_suppresses_directional_signals_in_choppy_market(monkeypatch, db_session):
"""Integration test: forcing every candle's regime to 'choppy' via
detect_market_regime() must suppress every directional BUY/SELL trade
that would otherwise have been opened — proves the regime filter
(added to close the backtest/live behavior gap) is actually wired
into the simulation path, not just computed and discarded."""
_, symbol = await _seed_symbol(db_session)
base = datetime.now(timezone.utc) - timedelta(hours=60)
await _seed_candles(db_session, symbol.id, "1h", base, 60, timedelta(hours=1), lambda i: i)
candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base)
precomputed = backtest_engine._precompute_indicators(candles, "1h")
# Force every candle to score as a strong BUY...
fake_score, _ = _make_fake_score_fn(buy_at=set(range(60)), sell_at=set())
monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake_score)
# ...but force the regime to "choppy" for all of them.
monkeypatch.setattr(backtest_engine, "detect_market_regime", lambda *a, **k: "choppy")
all_signals, trades = backtest_engine._simulate_trades(candles, precomputed, Decimal("10"))
assert trades == []
assert all_signals == []
async def test_regime_filter_allows_directional_signals_in_trending_market(monkeypatch, db_session):
"""Same setup as the choppy test, but with a regime that should NOT
suppress anything — confirms the filter is regime-specific, not a
blanket kill switch."""
_, symbol = await _seed_symbol(db_session)
base = datetime.now(timezone.utc) - timedelta(hours=60)
await _seed_candles(db_session, symbol.id, "1h", base, 60, timedelta(hours=1), lambda i: i)
candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base)
precomputed = backtest_engine._precompute_indicators(candles, "1h")
fake_score, _ = _make_fake_score_fn(buy_at={35}, sell_at=set())
monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake_score)
monkeypatch.setattr(backtest_engine, "detect_market_regime", lambda *a, **k: "trending")
all_signals, trades = backtest_engine._simulate_trades(candles, precomputed, Decimal("10"))
assert len(trades) == 1
assert trades[0]["direction"] == "LONG"
+70
View File
@@ -16,6 +16,7 @@ from app.services.signal_scoring import (
SQUEEZE_ALERT,
STRONG_BUY,
STRONG_SELL,
_apply_regime_filter,
_calculate_pnl,
_classify_signal_bb,
_classify_signal_combined,
@@ -195,6 +196,75 @@ class TestClassifySignalCombined:
)
assert raw_scores["double_bb_rsi"] == 0.0
def test_market_regime_none_leaves_classification_unchanged(self):
"""Default behavior (no regime data) must be identical to before
market_regime was added as a parameter — every other test in this
class already relies on that, this just makes it explicit."""
signal_type, strength, _confidence, _raw_scores = _classify_signal_combined(
115.0, self.NO_SQUEEZE_BB, rsi=[65], sma=[102],
macd_data=None, st_data=None, vol_data=None,
market_regime=None,
)
assert (signal_type, strength) == (BUY, "MODERATE")
def test_market_regime_choppy_suppresses_an_otherwise_valid_buy(self):
signal_type, strength, confidence, _raw_scores = _classify_signal_combined(
115.0, self.NO_SQUEEZE_BB, rsi=[65], sma=[102],
macd_data=None, st_data=None, vol_data=None,
market_regime="choppy",
)
assert (signal_type, strength) == (None, None)
def test_market_regime_does_not_suppress_caution_override(self):
"""Regime filtering only governs directional BUY/SELL calls — the
CAUTION_*/SQUEEZE_ALERT overrides bypass it entirely, same as live
trading's original behavior."""
signal_type, strength, confidence, _raw_scores = _classify_signal_combined(
115.0, self.NO_SQUEEZE_BB, rsi=[80], sma=[102],
macd_data=None, st_data=None, vol_data=None,
market_regime="choppy",
)
assert (signal_type, strength, confidence) == (CAUTION_SHORT, "MODERATE", 0.5)
class TestApplyRegimeFilter:
def test_non_directional_signal_passes_through_for_any_regime(self):
for regime in ("choppy", "sideways", "volatile", "trending", "neutral", None):
assert _apply_regime_filter(None, None, 0.7, regime) == (None, None, 0.7)
assert _apply_regime_filter(CAUTION_LONG, "MODERATE", 0.5, regime) == (CAUTION_LONG, "MODERATE", 0.5)
assert _apply_regime_filter(SQUEEZE_ALERT, "MODERATE", 0.5, regime) == (SQUEEZE_ALERT, "MODERATE", 0.5)
def test_choppy_suppresses_directional_signal(self):
assert _apply_regime_filter(BUY, "MODERATE", 0.8, "choppy") == (None, None, 0.8)
assert _apply_regime_filter(STRONG_SELL, "STRONG", 0.9, "choppy") == (None, None, 0.9)
def test_sideways_suppresses_directional_signal(self):
assert _apply_regime_filter(SELL, "MODERATE", 0.6, "sideways") == (None, None, 0.6)
def test_volatile_downgrades_strong_and_caps_confidence(self):
signal_type, strength, confidence = _apply_regime_filter(STRONG_BUY, "STRONG", 0.9, "volatile")
assert (signal_type, strength) == (BUY, "WEAK")
assert confidence == 0.3
signal_type, strength, confidence = _apply_regime_filter(STRONG_SELL, "STRONG", 0.95, "volatile")
assert (signal_type, strength) == (SELL, "WEAK")
assert confidence == 0.3
def test_volatile_keeps_moderate_signal_type_but_still_caps_confidence(self):
signal_type, strength, confidence = _apply_regime_filter(BUY, "MODERATE", 0.9, "volatile")
assert signal_type == BUY # not STRONG, nothing to downgrade
assert strength == "WEAK"
assert confidence == 0.3
def test_volatile_never_raises_confidence(self):
# min(confidence, 0.3) must not increase an already-low confidence.
_s, _st, confidence = _apply_regime_filter(BUY, "MODERATE", 0.1, "volatile")
assert confidence == 0.1
def test_trending_neutral_and_unknown_regimes_pass_through_unchanged(self):
for regime in ("trending", "neutral", "breakout", "squeeze", None, "something_unrecognized"):
assert _apply_regime_filter(BUY, "MODERATE", 0.8, regime) == (BUY, "MODERATE", 0.8)
class TestCalculatePnl:
def test_long_profit(self):
@@ -150,6 +150,58 @@ class TestCloseStaleTradesStopLossTakeProfit:
assert saved.exit_reason == "TARGET"
class TestCloseStaleTradesRegimeSourcing:
"""close_stale_trades used to bucket its own regime from raw ATR%
thresholds (>8%→volatile, <0.8%→choppy, etc). That was an independent,
cruder definition than the ADX+BB+Choppiness+Efficiency-Ratio one
live signal generation uses to filter entries (detect_market_regime,
via get_indicators()) — the two could disagree on what "volatile"
means for the same symbol at the same time. This proves the SL/TP
sizing path now sources its regime label from that same shared,
cached function instead of its own heuristic."""
async def test_uses_shared_get_indicators_for_regime_not_its_own_heuristic(self, session_factory, monkeypatch):
monkeypatch.setattr(signal_service, "async_session_factory", session_factory)
calls: list[tuple[str, str, str]] = []
async def fake_get_indicators(db, symbol, exchange, timeframe):
calls.append((symbol, exchange, timeframe))
return {"market_regime": "trending"}
monkeypatch.setattr(signal_service, "get_indicators", fake_get_indicators)
async with session_factory() as db:
user = make_user()
db.add(user)
exchange = Exchange(name="mexc", display_name="MEXC")
db.add(exchange)
await db.flush()
sym = Symbol(symbol="BTC/USDT", base="BTC", quote="USDT", exchange_id=exchange.id)
db.add(sym)
await db.flush()
# >=16 candles so close_stale_trades enters its ATR/regime branch.
base_time = datetime.now(timezone.utc) - timedelta(hours=20)
for i in range(20):
price = Decimal("100") + Decimal(i % 3)
db.add(Candle(
symbol_id=sym.id, timeframe="1h", timestamp=base_time + timedelta(hours=i),
open=price, high=price + 1, low=price - 1, close=price, volume=Decimal("1000"),
))
trade = HypotheticalTrade(
user_id=user.id, symbol="BTC/USDT", exchange="mexc", timeframe="1h",
direction="LONG", entry_price=Decimal("100"),
entry_time=datetime.now(timezone.utc) - timedelta(hours=1),
quantity=Decimal("1"), status="OPEN",
)
db.add(trade)
await db.commit()
await signal_service.close_stale_trades()
assert calls == [("BTC/USDT", "mexc", "1h")]
class TestCloseStaleTradesTrailingStop:
async def test_closes_when_price_drops_through_trailing_stop(self, session_factory, monkeypatch):
monkeypatch.setattr(signal_service, "async_session_factory", session_factory)