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
+40 -1
View File
@@ -24,11 +24,13 @@ from app.services.indicator_service import (
_find_pivot_highs, _find_pivot_lows,
_find_pivot_highs_levels, _find_pivot_lows_levels,
_detect_bos, _detect_choch, _detect_order_blocks,
adx, atr, detect_market_regime,
)
from app.services.signal_scoring import (
_classify_signal_combined,
_compute_adjusted_score,
_score_to_signal,
_apply_regime_filter,
STRONG_BUY, BUY, STRONG_SELL, SELL,
)
@@ -41,8 +43,13 @@ MIN_CANDLES = 30
# a little larger than that are enough — the point is each candle's cost
# becomes O(window), not O(candle_index), which is what made the old
# "slice the whole precomputed array up to now" approach O(n^2) overall.
_BB_WINDOW = 20
_BB_WINDOW = 25 # >= detect_market_regime()'s own bb-squeeze lookback (20), with margin
_SHORT_WINDOW = 3
# detect_market_regime() also calls efficiency_ratio()/choppiness_index()
# internally (period=14) on whatever prices/highs/lows it's given — pass a
# bounded tail, not the whole growing history, for the same O(n), not
# O(n^2), reason as everything else in this module.
_REGIME_WINDOW = 30
# SMC (market_structure) and divergence detection are built from pivot
# points (local highs/lows), not rolling windows — the old approach
# precomputed a single current-state snapshot (bos/choch/trend/
@@ -161,6 +168,13 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
price_pivot_highs_full = _find_pivot_highs(close_prices_full, _DIVERGENCE_PIVOT_LOOKBACK, _DIVERGENCE_PIVOT_LOOKBACK)
price_pivot_lows_full = _find_pivot_lows(close_prices_full, _DIVERGENCE_PIVOT_LOOKBACK, _DIVERGENCE_PIVOT_LOOKBACK)
# ADX + ATR feed detect_market_regime() — the same regime filter live
# trading applies (candle_service.py) so backtest results reflect the
# signals live trading would actually have acted on, not the larger
# set every one of the 13 algorithms would produce unfiltered.
adx_full = adx(candle_dicts_full, period=14) or {"adx": [], "plus_di": [], "minus_di": []}
atr_full = atr(candle_dicts_full, period=14) or []
# Pre-build MTF candles ONCE per MTF config
mtf_precomputed = []
for mtf_name, mtf_mult, mtf_w in mtf_config:
@@ -207,6 +221,8 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
"swing_lows_full": swing_lows_full,
"price_pivot_highs_full": price_pivot_highs_full,
"price_pivot_lows_full": price_pivot_lows_full,
"adx_full": adx_full,
"atr_full": atr_full,
"mtf_precomputed": mtf_precomputed,
}
@@ -243,6 +259,8 @@ def _compute_scores_series(
swing_lows_full = precomputed["swing_lows_full"]
price_pivot_highs_full = precomputed["price_pivot_highs_full"]
price_pivot_lows_full = precomputed["price_pivot_lows_full"]
adx_full = precomputed["adx_full"]
atr_full = precomputed["atr_full"]
mtf_precomputed = precomputed["mtf_precomputed"]
macd_hist_full = macd_full.get("histogram") if macd_full else None
@@ -377,6 +395,23 @@ def _compute_scores_series(
rsi_div, macd_div, smc_data, mtf_votes or None,
)
# Market regime — same multi-factor classification live trading
# uses (candle_service.py) to filter/downgrade directional signals
# in choppy/sideways/volatile conditions (see _apply_regime_filter
# 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)
market_regime = detect_market_regime(
adx_data, bb_data, atr_pct, vb_data,
prices=close_prices_full[regime_start:i + 1],
highs=[c["high"] for c in candle_dicts_full[regime_start:i + 1]],
lows=[c["low"] for c in candle_dicts_full[regime_start:i + 1]],
)
results.append({
"index": i,
"timestamp": candles[i].timestamp.isoformat(),
@@ -385,6 +420,7 @@ def _compute_scores_series(
"override_strength": override_strength,
"adjusted_score": adjusted_score,
"confidence": confidence,
"market_regime": market_regime,
})
return results
@@ -417,6 +453,9 @@ def _simulate_from_scores(
signal_type, strength = entry["override_signal"], entry["override_strength"]
else:
signal_type, strength = _score_to_signal(entry["adjusted_score"], strong_threshold, signal_threshold)
signal_type, strength, _confidence = _apply_regime_filter(
signal_type, strength, entry["confidence"], entry.get("market_regime"),
)
if signal_type:
all_signals.append({
+51
View File
@@ -522,6 +522,50 @@ def _score_to_signal(
return None, None
_DIRECTIONAL_SIGNALS = {STRONG_BUY, BUY, STRONG_SELL, SELL}
def _apply_regime_filter(
signal_type: Optional[str],
strength: Optional[str],
confidence: float,
market_regime: Optional[str],
) -> tuple[Optional[str], Optional[str], float]:
"""Suppress or downgrade directional signals based on market regime.
Choppy/sideways markets (low ADX, high Choppiness Index, low
Efficiency Ratio — see `indicator_service.detect_market_regime`)
generate false trend-following signals, so directional signals are
suppressed entirely. Volatile markets aren't wrong, just risky —
STRONG signals get downgraded and confidence is capped.
This used to live only in signal_service.py's live-trading path,
which meant backtest/walk-forward never applied it and so
systematically overestimated trade frequency and risk exposure
relative to what live trading actually does. Factored out here so
every caller — live and backtest alike — applies the same rule.
Non-directional results (None, or the CAUTION_*/SQUEEZE_ALERT
overrides) pass through unchanged — regime only governs whether/how
strongly to act on a directional BUY/SELL call.
"""
if signal_type not in _DIRECTIONAL_SIGNALS:
return signal_type, strength, confidence
if market_regime in ("choppy", "sideways"):
return None, None, confidence
if market_regime == "volatile":
confidence = min(confidence, 0.3)
strength = "WEAK"
if signal_type == STRONG_BUY:
signal_type = BUY
elif signal_type == STRONG_SELL:
signal_type = SELL
return signal_type, strength, confidence
def _classify_signal_combined(
close_price: float,
bb: dict[str, list[float]],
@@ -544,6 +588,7 @@ def _classify_signal_combined(
enabled_strategies: list[str] | None = None,
strong_threshold: float = 4.0,
signal_threshold: float = 1.0,
market_regime: str | None = None,
) -> tuple[Optional[str], Optional[str], float, dict[str, float]]:
"""Classify market state using 13-algorithm voting with win-rate boosting.
@@ -569,6 +614,11 @@ def _classify_signal_combined(
— left at their defaults for live trading; walk-forward backtesting
overrides them during parameter search.
*market_regime*, if given, suppresses directional signals in choppy/
sideways conditions and downgrades them in volatile ones — see
`_apply_regime_filter`. Left at its default (None = no filtering) for
callers that don't have regime data (e.g. MTF sub-timeframe votes).
Returns (signal_type, strength, confidence, raw_scores) where
confidence is a 0-1 float and raw_scores is a dict of all 9
algorithm scores for ML feature collection.
@@ -582,6 +632,7 @@ def _classify_signal_combined(
return override_signal, override_strength, confidence, raw_scores
signal_type, strength = _score_to_signal(adjusted_score, strong_threshold, signal_threshold)
signal_type, strength, confidence = _apply_regime_filter(signal_type, strength, confidence, market_regime)
return signal_type, strength, confidence, raw_scores
+24 -42
View File
@@ -302,43 +302,23 @@ async def _do_analysis(
market_regime: str | None = indicators.get("market_regime")
logger.debug("Market regime for %s: %s", symbol, market_regime)
# Classify signal using combined 13-algorithm voting with win-rate boosting
# Classify signal using combined 13-algorithm voting with win-rate
# boosting. market_regime suppresses choppy/sideways directional
# signals and downgrades volatile ones — see _apply_regime_filter in
# signal_scoring.py. This used to be a manual post-hoc check here,
# only applied to live trading; it's now inside the shared classifier
# so backtest/walk-forward apply the identical rule instead of
# over-estimating trade frequency relative to what live trading does.
signal_type, strength, confidence, algo_scores = _classify_signal_combined(
latest_close, bb, rsi, sma, macd_data, st_data, vol_data, ichi_data,
rsi_div, macd_div, smc_data, mtf_votes, obv_data,
stoch_rsi_data, mfi_data, fvg_data, candlestick_score, rates,
enabled_strategies=enabled_strategies,
market_regime=market_regime,
)
if signal_type is None:
return
# ── Market Regime signal filter ──
directional_signals = {STRONG_BUY, BUY, STRONG_SELL, SELL}
if market_regime == "choppy" and signal_type in directional_signals:
logger.debug(
"Suppressing %s signal for %s — choppy market, avoid trading",
signal_type, symbol,
)
return
elif market_regime == "sideways" and signal_type in directional_signals:
logger.debug(
"Suppressing %s signal for %s — market regime is sideways",
signal_type, symbol,
)
return
elif market_regime == "volatile" and signal_type in directional_signals:
# Downgrade ALL directional signals in volatile conditions
confidence = min(confidence, 0.3)
strength = "WEAK"
if signal_type == STRONG_BUY:
signal_type = BUY
elif signal_type == STRONG_SELL:
signal_type = SELL
logger.debug(
"Downgraded %s → %s for %s — volatile market regime",
signal_type, signal_type if signal_type in (BUY, SELL) else "BUY/SELL", symbol,
)
# Build indicators snapshot
snapshot = {
"close": latest_close,
@@ -1257,20 +1237,22 @@ async def close_stale_trades(
# SL/TP are prices, convert to percentage distance from entry
from app.services.risk_manager import AdaptiveSLTPOptimizer
sltp = AdaptiveSLTPOptimizer()
# Detect regime from ATR percentile (dynamic, not hardcoded)
if atr_pct is not None:
atr_f = float(atr_pct)
if atr_f > 8:
regime = "volatile"
elif atr_f > 5:
regime = "trending"
elif atr_f < 1.5:
regime = "sideways"
elif atr_f < 0.8:
regime = "choppy"
else:
regime = "neutral"
else:
# Regime from the same multi-factor detector live
# signal generation uses (ADX + BB + ATR% + Choppiness
# + Efficiency Ratio — see indicator_service.
# detect_market_regime), not a standalone ATR-percentile
# heuristic. Keeps SL/TP sizing consistent with
# whatever regime allowed/filtered the entry signal in
# the first place, instead of two independent
# definitions of "volatile"/"choppy"/etc that could
# disagree. get_indicators() is TTL-cached per
# (symbol, exchange, timeframe), so this is usually a
# cache hit shared with the live signal-generation
# loop, not a fresh recompute.
try:
cached_indicators = await get_indicators(db, trade.symbol, trade.exchange, trade.timeframe)
regime = cached_indicators.get("market_regime") or "neutral"
except Exception:
regime = "neutral"
result = sltp.compute_sl_tp(
atr=float(latest_atr),
+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)
+86
View File
@@ -0,0 +1,86 @@
# Theo dõi đánh giá dự án Trading Portal — v8
> **Ngày đánh giá gốc:** 2026-07-03
> **Cập nhật v1-v7:** xem các file `theo_doi_trading-portal_v1.md`…`v7.md`
> **Cập nhật v8 (lần này):** 2026-07-04 — wire ADX/regime detection vào làm bộ lọc tín hiệu (dùng chung giữa live trading và backtest/walk-forward), sau đó hợp nhất 2 hệ thống nhận diện regime độc lập trước đây thành 1
> **Người thực hiện:** Claude (Sonnet 5), theo yêu cầu của tien.a.le@accenture.com
> **Quy ước đặt tên:** Mỗi lần có thay đổi lớn → tạo bản mới `theo_doi_trading-portal_v9.md`, ... giữ nguyên các bản cũ làm lịch sử.
---
## 1. Tổng quan dự án
(Không đổi — xem [theo_doi_trading-portal_v2.md](theo_doi_trading-portal_v2.md) mục 1.)
---
## 2. Bối cảnh: câu hỏi "13 thuật toán còn thiếu gì không?"
Sau khi hoàn tất Walk-Forward Optimization (v7), người dùng hỏi có nên bổ sung thuật toán thứ 14 vào hệ thống vote không. Sau khi rà soát, phát hiện:
- **Không nên thêm thuật toán mới** — 13 thuật toán hiện tại đã phủ đầy đủ các nhóm chính (trend/momentum/volume/price-action/đa khung thời gian), và hệ thống đã có cơ chế "correlation dampening" chính vì lo ngại thêm indicator chỉ gây thừa/nhiễu.
- **Phát hiện quan trọng hơn:** `indicator_service.py` đã có sẵn `adx()`, `atr()`, `vwap()`, `detect_market_regime()` — nhưng **không cái nào được đưa vào hệ thống 13-vote chấm điểm tín hiệu**. Chúng chỉ phục vụ risk sizing (`risk_manager.py`), hoàn toàn tách biệt khỏi logic tạo tín hiệu.
- **Phát hiện phụ:** `volume_profile()`, `choppiness_index()`, `efficiency_ratio()` đã viết sẵn nhưng hoàn toàn không dùng ở đâu (kể cả không có test).
- **Phát hiện phụ 2:** có **2 hệ thống nhận diện "market regime" độc lập, không đồng bộ** đang chạy song song:
1. `indicator_service.detect_market_regime()` (đa yếu tố: ADX + độ rộng Bollinger Band + ATR% + Choppiness Index + Efficiency Ratio) — tính trong `candle_service.py`, dùng để **lọc/hạ cấp tín hiệu vào lệnh mới** trong `signal_service.py` (live trading only).
2. Một heuristic đơn giản dựa thuần trên ngưỡng ATR% (`>8%→volatile, >5%→trending, <1.5%→sideways, <0.8%→choppy`) tính riêng trong `close_stale_trades()` — dùng để **định cỡ SL/TP động** cho các lệnh đang mở.
Người dùng yêu cầu xử lý tuần tự: (1) wire hệ thống #1 vào làm bộ lọc dùng chung, rồi (2) hợp nhất 2 hệ thống lại làm một.
---
## 3. Bước 1: Wire ADX/Regime vào bộ lọc tín hiệu dùng chung
### 3.1 Phát hiện khi đào sâu
Bộ lọc regime (`detect_market_regime()` → suppress tín hiệu ở choppy/sideways, hạ cấp STRONG→thường ở volatile) **đã tồn tại từ trước** — nhưng chỉ nằm trong `signal_service.py` (lớp orchestration của live trading), hoàn toàn tách biệt khỏi `signal_scoring.py` (lớp chấm điểm thuần, dùng chung bởi cả live trading lẫn `backtest_engine.py`). Hệ quả: **backtest và Walk-Forward trước đây không bao giờ áp dụng bộ lọc này** — chúng đánh giá "lạc quan" hơn thực tế, vì live trading có cơ chế né tránh thị trường choppy/sideways mà backtest không mô phỏng lại.
### 3.2 Giải pháp
- Thêm hàm thuần `_apply_regime_filter()` vào `signal_scoring.py` — dùng chung cho mọi caller.
- Thêm tham số optional `market_regime: str | None = None` vào `_classify_signal_combined()` (mặc định `None` = không lọc, tương thích ngược 100% với mọi caller cũ, kể cả vote MTF nội bộ không cần lọc regime).
- `backtest_engine.py`: thêm `adx()`/`atr()` vào precompute (đúng như các indicator dạng rolling-window khác — tính 1 lần cho toàn bộ dữ liệu), tính `market_regime` mỗi nến bằng `detect_market_regime()` với cửa sổ giới hạn (giống hệt công thức live trading dùng trong `candle_service.py`), lưu vào score series. `_simulate_from_scores()` áp `_apply_regime_filter()` sau khi xác định `signal_type` theo ngưỡng — đúng kiến trúc "tính 1 lần, áp dụng rẻ mỗi tổ hợp tham số" đã xây ở v7.
- `signal_service.py`: truyền `market_regime=market_regime` vào `_classify_signal_combined(...)`, xóa khối lọc thủ công trùng lặp (giờ dư thừa).
- Đo hiệu năng: 32.000 nến tăng từ 3.2s → 6.3s (do thêm tính ADX/ATR/regime mỗi nến) — vẫn tuyến tính, không quay lại O(n²). Ước tính Walk-Forward 15m ở lookback 3 năm mặc định ~60 giây (so với ~30 giây trước khi thêm bộ lọc này) — vẫn chấp nhận được cho 1 request đồng bộ.
---
## 4. Bước 2: Hợp nhất 2 hệ thống Regime
### 4.1 Giải pháp
`close_stale_trades()` (vòng lặp định kỳ kiểm tra lệnh đang mở để tính SL/TP động) đã import sẵn `get_indicators()` từ `candle_service.py` — chính là hàm **đã có cache TTL theo từng (symbol, exchange, timeframe)** và đã tính `market_regime` bằng `detect_market_regime()` đa yếu tố cho mục đích lọc tín hiệu. Thay vì tự tính lại regime bằng heuristic ATR% riêng, `close_stale_trades()` giờ gọi thẳng `get_indicators()` và đọc `market_regime` từ đó.
**Lợi ích:**
- Toàn hệ thống giờ chỉ còn **1 định nghĩa "volatile"/"choppy"/"trending"** duy nhất — dùng để cả lọc tín hiệu vào lệnh mới VÀ định cỡ SL/TP cho lệnh đang mở, tránh trường hợp 2 hệ thống "cãi nhau" (ví dụ hệ #1 nói "trending" nhưng hệ #2 nói "sideways" cho cùng 1 symbol cùng thời điểm).
- Vì `get_indicators()` đã có cache sẵn (dùng chung với vòng lặp tạo tín hiệu live), lệnh gọi thêm này **thường chỉ là cache hit**, không phát sinh truy vấn DB/tính toán mới đáng kể.
- Tính ATR tuyệt đối (đơn vị giá, cần cho khoảng cách SL/TP) vẫn giữ nguyên cách tính cũ trong `close_stale_trades()` — chỉ thay phần "quy đổi ATR% thành nhãn regime" bằng nguồn dùng chung.
### 4.2 Các file thay đổi
- `backend/app/services/signal_scoring.py` — thêm `_apply_regime_filter()`, thêm tham số `market_regime` vào `_classify_signal_combined()`.
- `backend/app/services/backtest_engine.py` — precompute `adx()`/`atr()`, tính `market_regime` mỗi nến trong `_compute_scores_series()`, áp bộ lọc trong `_simulate_from_scores()`.
- `backend/app/services/signal_service.py` — dùng bộ lọc dùng chung (xóa khối lọc thủ công cũ trong `_do_analysis`), thay heuristic ATR% riêng trong `close_stale_trades()` bằng `get_indicators()` dùng chung.
- **Test mới:** `TestApplyRegimeFilter` + 3 test mở rộng `TestClassifySignalCombined` trong `test_signal_scoring.py`; 4 test mới trong `test_backtest_engine.py` (regime hợp lệ trong score series, lọc tín hiệu ở chế độ choppy, không lọc ở chế độ trending); 1 test mới `TestCloseStaleTradesRegimeSourcing` trong `test_signal_service_async.py` xác nhận `close_stale_trades()` dùng `get_indicators()` dùng chung thay vì tự tính. **Tổng 170 test pass** (tăng từ 156).
---
## 5. Toàn bộ nhược điểm & rủi ro — bổ sung mới trong v8
| # | Vấn đề | Trạng thái |
|---|---|---|
| bb | (mới) Bộ lọc regime chỉ áp dụng cho live trading, backtest/walk-forward không mô phỏng lại | ✅ Đã sửa (mục 3) |
| cc | (mới) 2 hệ thống nhận diện regime độc lập, không đồng bộ (entry-filter vs SL/TP sizing) | ✅ Đã hợp nhất (mục 4) |
| dd | (mới) `volume_profile()`, `choppiness_index()`, `efficiency_ratio()` viết sẵn nhưng không dùng ở đâu | ⏳ Ghi nhận, không ưu tiên — code chết, có thể dọn hoặc tận dụng sau |
(Danh sách đầy đủ a→aa xem [theo_doi_trading-portal_v7.md](theo_doi_trading-portal_v7.md) mục 3.)
---
## 6. Lịch sử phiên bản
| Phiên bản | Ngày | Thay đổi |
|---|---|---|
| v0-v6 | 2026-07-03 — 2026-07-04 | Xem file tương ứng |
| v7 | 2026-07-04 | (m) Walk-Forward Backtest Optimization, sửa O(n²)+look-ahead ở SMC/Divergence, đồng bộ timeframe + giờ hiển thị GMT+7. 156 test pass. |
| v8 | 2026-07-04 | Wire ADX/regime detection vào bộ lọc tín hiệu dùng chung cho cả live trading và backtest/walk-forward (trước đó chỉ live trading có); hợp nhất 2 hệ thống regime độc lập (entry-filter dùng `detect_market_regime()` đa yếu tố, SL/TP sizing từng dùng heuristic ATR% riêng) thành 1, dùng `get_indicators()` cache sẵn. Ghi nhận `volume_profile`/`choppiness_index`/`efficiency_ratio` là code chết chưa dùng. 170 test pass. |