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