diff --git a/backend/app/services/backtest_engine.py b/backend/app/services/backtest_engine.py index bf53f7d..b4edbc7 100644 --- a/backend/app/services/backtest_engine.py +++ b/backend/app/services/backtest_engine.py @@ -33,6 +33,7 @@ from app.services.signal_scoring import ( _apply_regime_filter, STRONG_BUY, BUY, STRONG_SELL, SELL, ) +from app.services.risk_manager import AdaptiveSLTPOptimizer # Min candles for warmup: BB(20) + RSI(14) + some room = 30 MIN_CANDLES = 30 @@ -48,6 +49,38 @@ MIN_CANDLES = 30 DEFAULT_TAKER_FEE_PCT = 0.001 DEFAULT_SLIPPAGE_PCT = 0.0005 +# ATR/regime-adaptive stop-loss and take-profit — mirrors the exact live +# logic in signal_service.py's close_stale_trades() (AdaptiveSLTPOptimizer +# + the same percentage floors), which used to run ONLY in live trading. +# Without this, backtest/walk-forward only ever exited on REVERSAL/ +# TIME_LIMIT/END_OF_DATA — a materially different (and much more lenient) +# exit rule than what live trading actually enforces, so reported +# performance didn't reflect how live positions actually get cut short. +_SLTP_OPTIMIZER = AdaptiveSLTPOptimizer() +_DEFAULT_SL_PCT = 5.0 # used when ATR isn't available yet (e.g. warmup) +_DEFAULT_TP_PCT = 10.0 +_MIN_SL_PCT = 3.0 # same floors close_stale_trades() applies +_MIN_TP_PCT = 6.0 + + +def _effective_sl_tp_pct( + entry_price: float, atr: float | None, regime: str | None, direction: str, +) -> tuple[float, float]: + """Percentage distance from entry at which SL/TP should fire, adapted + to current ATR and market regime — see _SLTP_OPTIMIZER above.""" + if not atr or atr <= 0 or entry_price <= 0: + return _DEFAULT_SL_PCT, _DEFAULT_TP_PCT + result = _SLTP_OPTIMIZER.compute_sl_tp( + atr=atr, entry_price=entry_price, regime=regime or "neutral", direction=direction, + ) + if direction == "LONG": + sl_pct = (entry_price - result["stop_loss"]) * 100.0 / entry_price + tp_pct = (result["take_profit"] - entry_price) * 100.0 / entry_price + else: + sl_pct = (result["stop_loss"] - entry_price) * 100.0 / entry_price + tp_pct = (entry_price - result["take_profit"]) * 100.0 / entry_price + return max(sl_pct, _MIN_SL_PCT), max(tp_pct, _MIN_TP_PCT) + def _fill_price(mark_price: float, direction: str, is_entry: bool, slippage_pct: float) -> float: """Simulate a realistic market-order fill price — slippage always moves @@ -121,6 +154,10 @@ _SHORT_WINDOW = 3 # 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 +# (kk) detect_market_regime()'s adaptive "volatile" threshold is a +# percentile of a symbol's own recent ATR% history — a bounded trailing +# window (not the whole growing history) for the same O(n) reason. +_ATR_HISTORY_WINDOW = 100 # 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/ @@ -245,6 +282,12 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict: # 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 [] + # (kk) ATR% history feeds detect_market_regime()'s adaptive "volatile" + # threshold — see _ATR_HISTORY_WINDOW above. + atr_pct_full = [ + (a / c * 100.0) if a and c and c > 0 else None + for a, c in zip(atr_full, close_prices_full) + ] # Pre-build MTF candles ONCE per MTF config mtf_precomputed = [] @@ -294,6 +337,7 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict: "price_pivot_lows_full": price_pivot_lows_full, "adx_full": adx_full, "atr_full": atr_full, + "atr_pct_full": atr_pct_full, "mtf_precomputed": mtf_precomputed, } @@ -332,6 +376,7 @@ def _compute_scores_series( price_pivot_lows_full = precomputed["price_pivot_lows_full"] adx_full = precomputed["adx_full"] atr_full = precomputed["atr_full"] + atr_pct_full = precomputed["atr_pct_full"] mtf_precomputed = precomputed["mtf_precomputed"] macd_hist_full = macd_full.get("histogram") if macd_full else None @@ -476,11 +521,13 @@ def _compute_scores_series( 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( 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]], + atr_pct_history=atr_pct_full[atr_history_start:i + 1], ) results.append({ @@ -492,6 +539,7 @@ def _compute_scores_series( "adjusted_score": adjusted_score, "confidence": confidence, "market_regime": market_regime, + "atr": last_atr, }) return results @@ -518,6 +566,14 @@ def _simulate_from_scores( adverse slippage a live market order would, instead of pricing fills at the exact candle close for free — see DEFAULT_TAKER_FEE_PCT/ DEFAULT_SLIPPAGE_PCT above. + + Open positions are also checked every candle against ATR/regime- + adaptive STOP_LOSS/TAKE_PROFIT levels (`_effective_sl_tp_pct`) — the + same exit rule `signal_service.py`'s close_stale_trades() enforces in + live trading — in addition to REVERSAL/TIME_LIMIT/END_OF_DATA. Without + this, backtest/walk-forward positions could only ever be cut short by + a fresh opposing signal or the max-hold timer, which is materially + more lenient than what live trading actually does. """ all_signals: list[dict] = [] trades: list[dict] = [] @@ -569,8 +625,25 @@ def _simulate_from_scores( ) if current_position and current_position["status"] == "OPEN": + direction = current_position["direction"] + entry_price = current_position["entry_price"] + sl_pct, tp_pct = _effective_sl_tp_pct( + entry_price, entry.get("atr"), entry.get("market_regime"), direction, + ) + price_diff_pct = abs(latest_close - entry_price) / entry_price * 100.0 if entry_price > 0 else 0.0 + is_adverse = (direction == "LONG" and latest_close < entry_price) or (direction == "SHORT" and latest_close > entry_price) + is_favorable = (direction == "LONG" and latest_close > entry_price) or (direction == "SHORT" and latest_close < entry_price) + hold = i - current_position["entry_index"] - if hold >= max_hold_candles: + if is_adverse and price_diff_pct >= sl_pct: + _close_position(current_position, latest_close, timestamp, "STOP_LOSS", fee_pct, slippage_pct) + trades.append(current_position) + current_position = None + elif is_favorable and price_diff_pct >= tp_pct: + _close_position(current_position, latest_close, timestamp, "TAKE_PROFIT", fee_pct, slippage_pct) + trades.append(current_position) + current_position = None + elif hold >= max_hold_candles: _close_position(current_position, latest_close, timestamp, "TIME_LIMIT", fee_pct, slippage_pct) trades.append(current_position) current_position = None diff --git a/backend/app/services/candle_service.py b/backend/app/services/candle_service.py index 2ce3eec..19ab6ff 100755 --- a/backend/app/services/candle_service.py +++ b/backend/app/services/candle_service.py @@ -500,13 +500,19 @@ 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 + # 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) - last_atr = raw_atr[-1] if raw_atr and len(raw_atr) > 0 else None - last_close = close_prices[-1] if close_prices else 1 - atr_pct_val = (last_atr / last_close * 100.0) if last_atr and last_close > 0 else None + 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 @@ -522,6 +528,7 @@ async def get_indicators( prices=close_prices, highs=high_prices, lows=low_prices, + atr_pct_history=atr_pct_history, ) computed["market_regime"] = regime diff --git a/backend/app/services/indicator_service.py b/backend/app/services/indicator_service.py index bf276e9..efd8019 100755 --- a/backend/app/services/indicator_service.py +++ b/backend/app/services/indicator_service.py @@ -1114,6 +1114,40 @@ def adx(candles: list[dict], period: int = 14) -> dict[str, list[Optional[float] # Market Regime Detection # ====================================================================== +# ── (kk) Adaptive volatile-regime threshold ── +# A fixed `atr_pct > 5.0` cutoff misclassifies regime for most symbols: +# BTC/ETH on 15m candles rarely exceed 1-2% ATR (so 5% would almost never +# fire, "volatile" never gets detected for majors), while a thin-liquidity +# altcoin or memecoin routinely trades 5%+ as its NORMAL state (so 5% +# would almost always fire, permanently downgrading/suppressing its +# signals). Percentile-based on the symbol's OWN recent ATR% history +# adapts automatically instead. +_MIN_ATR_HISTORY_FOR_ADAPTIVE_THRESHOLD = 20 +_VOLATILE_PERCENTILE = 0.90 +_MIN_VOLATILE_THRESHOLD_PCT = 1.0 # floor: don't flag "volatile" on trivial upticks for an unusually calm symbol +_MAX_VOLATILE_THRESHOLD_PCT = 15.0 # ceiling: sanity bound against a data glitch skewing the whole history +_DEFAULT_VOLATILE_THRESHOLD_PCT = 5.0 # fallback when there's no history yet (new symbol, cold cache) + + +def _adaptive_volatile_threshold( + atr_pct_history: list[Optional[float]] | None, + default: float = _DEFAULT_VOLATILE_THRESHOLD_PCT, +) -> float: + """The `atr_pct` cutoff above which `detect_market_regime` calls the + market "volatile" — the 90th percentile of a symbol's own recent ATR% + history, so each symbol is judged against its own normal behavior + instead of one hardcoded number. Falls back to `default` when there + isn't enough history yet. + """ + if not atr_pct_history: + return default + valid = sorted(v for v in atr_pct_history if v is not None and v > 0) + if len(valid) < _MIN_ATR_HISTORY_FOR_ADAPTIVE_THRESHOLD: + return default + idx = min(int(len(valid) * _VOLATILE_PERCENTILE), len(valid) - 1) + return max(_MIN_VOLATILE_THRESHOLD_PCT, min(valid[idx], _MAX_VOLATILE_THRESHOLD_PCT)) + + def detect_market_regime( adx_data: dict[str, list[Optional[float]]], bb: dict[str, list[Optional[float]]], @@ -1123,6 +1157,7 @@ def detect_market_regime( highs: list[float] | None = None, lows: list[float] | None = None, lookback: int = 20, + atr_pct_history: list[Optional[float]] | None = None, ) -> str: """Classify the current market regime using multi-factor analysis. @@ -1134,6 +1169,10 @@ def detect_market_regime( - "breakout" : BB squeeze + volume spike - "squeeze" : BB very narrow, low volatility before breakout - "choppy" : high CHOP, low ER — completely avoid + + `atr_pct_history`, if given, adapts the "volatile" cutoff to this + symbol's own recent ATR% distribution instead of one fixed number + shared by every symbol — see `_adaptive_volatile_threshold`. """ adx_vals = adx_data.get("adx", []) adx_last = adx_vals[-1] if adx_vals and len(adx_vals) >= 1 else None @@ -1183,7 +1222,8 @@ def detect_market_regime( if vol_last is True: vol_spike = True - is_volatile = atr_pct is not None and atr_pct > 5.0 + volatile_threshold = _adaptive_volatile_threshold(atr_pct_history) + is_volatile = atr_pct is not None and atr_pct > volatile_threshold # ── Enhanced classification ── # Priority: squeeze → breakout → choppy → volatile → trending → sideways → neutral diff --git a/backend/app/services/signal_scoring.py b/backend/app/services/signal_scoring.py index 5d3ccd1..2715055 100644 --- a/backend/app/services/signal_scoring.py +++ b/backend/app/services/signal_scoring.py @@ -37,6 +37,56 @@ SQUEEZE_ALERT = "SQUEEZE_ALERT" # Minimum distance from BB bounds to filter noise MIN_BB_DISTANCE_PCT = Decimal("0.001") # 0.1% +# --------------------------------------------------------------------------- +# P1-16 (jj): Pairwise-weighted correlation dampening +# --------------------------------------------------------------------------- +# Strategies in the same group are correlated; dampen when multiple group +# members agree (same sign) to avoid overconfidence. +# +# A uniform 1/sqrt(count) dampening treats every co-active pair in a group +# as equally, fully correlated — but that's not true within a group: +# StochRSI is *literally derived from* RSI (empirically correlated >0.8), +# while MFI (volume-weighted RSI) typically correlates much less (~0.5) +# with either. Dampening them identically over-penalizes MFI's genuinely +# complementary volume signal while under-penalizing the near-duplicate +# RSI/StochRSI pair. Same story for the trend group (MACD/SuperTrend more +# alike than either is to Ichimoku) and volume group (a volume spike and +# cumulative OBV flow measure related but distinct things). +# +# These coefficients are domain-informed estimates, not yet calibrated +# against this system's own historical signal correlations (see +# theo_doi_trading-portal_v11.md) — but they are strictly more accurate +# than assuming every co-active pair in a group is equally (~1.0) +# correlated, which the old uniform formula implicitly did. Every pair +# that can actually occur within a group is listed explicitly; the +# default is only a safety net for future additions. +CORRELATION_GROUPS: list[list[str]] = [ + ["double_bb_rsi", "stoch_rsi", "mfi"], # Oscillator group + ["macd_crossover", "supertrend", "ichimoku"], # Trend group + ["volume_breakout", "obv"], # Volume group + ["divergence", "smc", "fvg", "candlestick"], # Pattern group +] +_DEFAULT_PAIR_CORRELATION = 0.5 +_PAIR_CORRELATION_WEIGHTS: dict[frozenset[str], float] = { + frozenset({"double_bb_rsi", "stoch_rsi"}): 0.85, # StochRSI is RSI re-normalized + frozenset({"double_bb_rsi", "mfi"}): 0.5, # MFI = volume-weighted RSI + frozenset({"stoch_rsi", "mfi"}): 0.5, + frozenset({"macd_crossover", "supertrend"}): 0.7, # both EMA/ATR trend-following, similar lag + frozenset({"macd_crossover", "ichimoku"}): 0.5, + frozenset({"supertrend", "ichimoku"}): 0.6, + frozenset({"volume_breakout", "obv"}): 0.4, # instantaneous spike vs. cumulative flow + frozenset({"divergence", "smc"}): 0.3, + frozenset({"divergence", "fvg"}): 0.2, + frozenset({"divergence", "candlestick"}): 0.2, + frozenset({"smc", "fvg"}): 0.35, # FVG is itself an ICT/SMC concept + frozenset({"smc", "candlestick"}): 0.2, + frozenset({"fvg", "candlestick"}): 0.2, +} + + +def _pair_correlation(a: str, b: str) -> float: + return _PAIR_CORRELATION_WEIGHTS.get(frozenset({a, b}), _DEFAULT_PAIR_CORRELATION) + def _get_bb_values(indicators: dict) -> dict[str, list[float]] | None: """Extract Bollinger Band values from indicators dict.""" @@ -449,32 +499,21 @@ def _compute_adjusted_score( for s in disabled: raw_scores[s] = 0.0 - # ── P1-16: Correlation dampening ── - # Strategies in the same group are highly correlated; dampen when - # multiple group members agree (same sign) to avoid overconfidence. - CORRELATION_GROUPS: list[list[str]] = [ - ["double_bb_rsi", "stoch_rsi", "mfi"], # Oscillator group - ["macd_crossover", "supertrend", "ichimoku"], # Trend group - ["volume_breakout", "obv"], # Volume group - ["divergence", "smc", "fvg", "candlestick"], # Pattern group - ] + # ── P1-16 (jj): Pairwise-weighted correlation dampening ── + # See CORRELATION_GROUPS/_PAIR_CORRELATION_WEIGHTS/_pair_correlation + # near the top of this module for the rationale. for group in CORRELATION_GROUPS: active = [(s, raw_scores[s]) for s in group if raw_scores[s] != 0.0] - if len(active) >= 2: - signs = [1 if v > 0 else -1 for _, v in active] - pos_count = sum(1 for s in signs if s > 0) - neg_count = sum(1 for s in signs if s < 0) - # Dampen: scale each strategy's score by 1/sqrt(count) - if pos_count >= 2: - dampen = 1.0 / (pos_count ** 0.5) - for strat, val in active: - if val > 0: - raw_scores[strat] = val * dampen - if neg_count >= 2: - dampen = 1.0 / (neg_count ** 0.5) - for strat, val in active: - if val < 0: - raw_scores[strat] = val * dampen + for strat, val in active: + same_sign_partners = [ + other_strat for other_strat, other_val in active + if other_strat != strat and (other_val > 0) == (val > 0) + ] + if not same_sign_partners: + continue + corr_sum = sum(_pair_correlation(strat, other_strat) for other_strat in same_sign_partners) + dampen = 1.0 / (1.0 + corr_sum) ** 0.5 + raw_scores[strat] = val * dampen # ── Apply win-rate boosting ── boosted_scores: dict[str, float] = {} diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 6b76695..b44b2ca 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import logging +import math from collections import defaultdict from datetime import datetime, timezone, timedelta from decimal import Decimal @@ -184,6 +185,21 @@ async def execute_signal_trade( except Exception: pass + # ── Serialize per-user trade-opening decisions (fix ll) ── + # The `with_for_update()` on all_open_trades below only locks rows + # that already exist — it can't stop a PHANTOM read: if two STRONG + # signals for different symbols arrive for the same user at nearly + # the same time, both transactions can lock the SAME existing open + # rows, but neither transaction's lock covers the other's brand-new + # INSERT (which doesn't exist yet to be locked). Both can then read + # the same `open_count`, both pass the MAX_OPEN_TRADES check, and + # both insert — overshooting the cap. Locking the user row itself + # (freshly, not the possibly-stale `first_user` fetched earlier) + # forces concurrent calls for this user through one at a time: the + # second call blocks here until the first commits, then re-reads + # open_count fresh and sees the first call's new trade. + await db.execute(select(User).where(User.id == first_user.id).with_for_update()) + # ── Hybrid eviction ── all_open_result = await db.execute( select(HypotheticalTrade) @@ -282,6 +298,19 @@ async def execute_signal_trade( avg_loss=pnl_stats.get("avg_loss", 2.0), confidence=signal_confidence, ) + # ── Portfolio-correlation dampening ── + # Kelly above is computed as if this were the only position — + # but crypto altcoins are typically highly correlated with each + # other (and with BTC), so a book full of same-direction + # positions carries much more compounded risk on a market-wide + # move than the per-trade Kelly fractions summed naively would + # suggest. Same-direction open positions here are the ones that + # actually stack that risk (opposite-direction positions net + # against it instead); dampen by 1/sqrt(n+1), the same + # correlated-vote dampening already used for the 13-algorithm + # signal system (see signal_scoring.py's CORRELATION_GROUPS). + same_direction_open = sum(1 for t in all_open_trades if t.direction == signal_direction) + kelly_pct *= 1.0 / math.sqrt(same_direction_open + 1) if kelly_pct > 0: trade_size = max(trade_size * Decimal(str(kelly_pct)), Decimal("1")) except Exception: diff --git a/backend/tests/test_backtest_engine.py b/backend/tests/test_backtest_engine.py index ab67daf..55cae86 100644 --- a/backend/tests/test_backtest_engine.py +++ b/backend/tests/test_backtest_engine.py @@ -155,6 +155,13 @@ async def test_simulate_trades_thresholds_control_signal_strength(monkeypatch, d async def test_simulate_trades_exits_on_max_hold_candles(monkeypatch, db_session): _, symbol = await _seed_symbol(db_session) base = datetime.now(timezone.utc) - timedelta(hours=40) + # A flat price series reads as "choppy" to detect_market_regime(), + # which suppresses directional signals outright (see + # _apply_regime_filter) — so this needs the same steadily-trending + # ramp the rest of the suite uses. With `max_hold_candles=1`, the + # TIME_LIMIT timer fires after only ~3.3% of favorable price movement + # (1 candle of the ramp), safely under the ATR-based TAKE_PROFIT floor + # (6%) so TIME_LIMIT is what actually closes it, not TAKE_PROFIT. await _seed_candles(db_session, symbol.id, "1h", base, 40, 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") @@ -165,14 +172,14 @@ async def test_simulate_trades_exits_on_max_hold_candles(monkeypatch, db_session monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake) _, trades = backtest_engine._simulate_trades( - candles, precomputed, Decimal("10"), max_hold_candles=5, + candles, precomputed, Decimal("10"), max_hold_candles=1, ) assert len(trades) == 1 trade = trades[0] assert trade["status"] == "CLOSED" assert trade["exit_reason"] == "TIME_LIMIT" - expected_exit_index = trade["entry_index"] + 5 + expected_exit_index = trade["entry_index"] + 1 assert trade["exit_time"] == candles[expected_exit_index].timestamp.isoformat() @@ -224,17 +231,20 @@ class TestFeeAndSlippage: candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base) precomputed = backtest_engine._precompute_indicators(candles, "1h") - # STRONG_BUY at candle 30 (price 30), STRONG_SELL at candle 35 - # (price 35) reverses and closes it — a straightforward winning - # LONG before costs. The STRONG_SELL that closes it also opens a + # STRONG_BUY at candle 30 (price 30), STRONG_SELL at candle 31 + # (price 31) reverses and closes it — a small, straightforward + # winning LONG before costs. Kept to 1 candle / ~3.3% so the new + # ATR-based SL/TP check (which runs every candle a position is + # open, see test_backtest_engine's TestAtrStopLossTakeProfit) + # doesn't fire first and change which exit path this test is + # actually exercising. The STRONG_SELL that closes it also opens a # new SHORT in the same step (existing reversal behavior), which - # then rides to END_OF_DATA — only the first (LONG) trade matters + # then rides its own exit — only the first (LONG) trade matters # for this assertion. - fake, _ = _make_fake_score_fn(buy_at={30}, sell_at={35}) + fake, _ = _make_fake_score_fn(buy_at={30}, sell_at={31}) monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake) _, trades = backtest_engine._simulate_trades(candles, precomputed, Decimal("10")) - assert len(trades) == 2 trade = trades[0] assert trade["direction"] == "LONG" assert trade["exit_reason"] == "REVERSAL" @@ -243,7 +253,7 @@ class TestFeeAndSlippage: slip = backtest_engine.DEFAULT_SLIPPAGE_PCT expected_entry = 30.0 * (1 + slip) expected_qty = 10.0 / expected_entry - expected_exit = 35.0 * (1 - slip) + expected_exit = 31.0 * (1 - slip) expected_gross = (expected_exit - expected_entry) * expected_qty expected_fees = (expected_entry + expected_exit) * expected_qty * fee_pct expected_net = expected_gross - expected_fees @@ -268,7 +278,7 @@ class TestFeeAndSlippage: candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base) precomputed = backtest_engine._precompute_indicators(candles, "1h") - fake, _ = _make_fake_score_fn(buy_at={30}, sell_at={35}) + fake, _ = _make_fake_score_fn(buy_at={30}, sell_at={31}) monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake) _, trades = backtest_engine._simulate_trades( @@ -277,9 +287,9 @@ class TestFeeAndSlippage: trade = trades[0] assert trade["direction"] == "LONG" assert trade["entry_price"] == pytest.approx(30.0) - assert trade["exit_price"] == pytest.approx(35.0) + assert trade["exit_price"] == pytest.approx(31.0) expected_qty = 10.0 / 30.0 - assert trade["pnl"] == pytest.approx((35.0 - 30.0) * expected_qty) + assert trade["pnl"] == pytest.approx((31.0 - 30.0) * expected_qty) assert trade["fees"] == pytest.approx(0.0) async def test_compute_stats_reports_total_fees(self, monkeypatch, db_session): @@ -311,6 +321,107 @@ def _build_candle_series(base, prices): ] +class TestAtrStopLossTakeProfit: + """Regression tests for fix (ii): backtest/walk-forward used to only + ever exit a position on REVERSAL/TIME_LIMIT/END_OF_DATA — a materially + more lenient rule than live trading, which also cuts positions on + ATR/regime-adaptive STOP_LOSS/TAKE_PROFIT (see + signal_service.py's close_stale_trades + AdaptiveSLTPOptimizer). + `_effective_sl_tp_pct`/the SL/TP check in `_simulate_from_scores` now + mirror that same rule. + """ + + def test_effective_sl_tp_pct_uses_atr_and_regime(self): + sl_pct, tp_pct = backtest_engine._effective_sl_tp_pct(100.0, 3.0, "trending", "LONG") + # trending: sl=1.5x ATR, tp=4.0x ATR -> 4.5%/12.0% of entry_price=100 + assert sl_pct == pytest.approx(4.5) + assert tp_pct == pytest.approx(12.0) + + def test_effective_sl_tp_pct_applies_floor_when_atr_implies_a_tiny_move(self): + sl_pct, tp_pct = backtest_engine._effective_sl_tp_pct(100.0, 1.0, "sideways", "LONG") + # sideways: sl=1.0x, tp=2.0x ATR -> raw 1.0%/2.0%, both below the + # 3%/6% floors close_stale_trades() applies. + assert sl_pct == pytest.approx(backtest_engine._MIN_SL_PCT) + assert tp_pct == pytest.approx(backtest_engine._MIN_TP_PCT) + + def test_effective_sl_tp_pct_falls_back_to_defaults_without_atr(self): + assert backtest_engine._effective_sl_tp_pct(100.0, None, "trending", "LONG") == ( + backtest_engine._DEFAULT_SL_PCT, backtest_engine._DEFAULT_TP_PCT, + ) + + def _make_scores_series(self, candles, closes, first_signal=None): + series = [] + for i, close in enumerate(closes): + series.append({ + "index": i, "timestamp": candles[i].timestamp.isoformat(), "close": close, + "override_signal": first_signal if i == 0 else None, + "override_strength": "STRONG" if i == 0 and first_signal else None, + "adjusted_score": 0.0, "confidence": 0.9, + "market_regime": "neutral", "atr": 2.0, + }) + return series + + def test_stop_loss_closes_long_position_when_price_drops_past_threshold(self): + # neutral regime: sl=1.2x ATR(2.0)=2.4% -> floored to 3.0%. + closes = [100, 100, 100, 94, 94, 94] + candles = _build_candle_series(datetime.now(timezone.utc), closes) + scores_series = self._make_scores_series(candles, closes, first_signal=backtest_engine.STRONG_BUY) + + _, trades = backtest_engine._simulate_from_scores( + candles, scores_series, Decimal("10"), max_hold_candles=100, fee_pct=0.0, slippage_pct=0.0, + ) + + assert len(trades) == 1 + assert trades[0]["direction"] == "LONG" + assert trades[0]["exit_reason"] == "STOP_LOSS" + assert trades[0]["exit_price"] == pytest.approx(94.0) + assert trades[0]["pnl"] < 0 + + def test_take_profit_closes_long_position_when_price_rises_past_threshold(self): + # neutral regime: tp=3.0x ATR(2.0)=6.0% -> exactly the floor, no rounding surprise. + closes = [100, 100, 100, 108, 108, 108] + candles = _build_candle_series(datetime.now(timezone.utc), closes) + scores_series = self._make_scores_series(candles, closes, first_signal=backtest_engine.STRONG_BUY) + + _, trades = backtest_engine._simulate_from_scores( + candles, scores_series, Decimal("10"), max_hold_candles=100, fee_pct=0.0, slippage_pct=0.0, + ) + + assert len(trades) == 1 + assert trades[0]["direction"] == "LONG" + assert trades[0]["exit_reason"] == "TAKE_PROFIT" + assert trades[0]["exit_price"] == pytest.approx(108.0) + assert trades[0]["pnl"] > 0 + + def test_short_position_stop_loss_fires_when_price_rises_past_threshold(self): + closes = [100, 100, 100, 106, 106, 106] + candles = _build_candle_series(datetime.now(timezone.utc), closes) + scores_series = self._make_scores_series(candles, closes, first_signal=backtest_engine.STRONG_SELL) + + _, trades = backtest_engine._simulate_from_scores( + candles, scores_series, Decimal("10"), max_hold_candles=100, fee_pct=0.0, slippage_pct=0.0, + ) + + assert len(trades) == 1 + assert trades[0]["direction"] == "SHORT" + assert trades[0]["exit_reason"] == "STOP_LOSS" + assert trades[0]["pnl"] < 0 + + def test_small_price_move_within_thresholds_does_not_trigger_sl_or_tp(self): + # 1% move, well under both the 3% SL and 6% TP floors. + closes = [100, 100, 100, 101, 101, 101] + candles = _build_candle_series(datetime.now(timezone.utc), closes) + scores_series = self._make_scores_series(candles, closes, first_signal=backtest_engine.STRONG_BUY) + + _, trades = backtest_engine._simulate_from_scores( + candles, scores_series, Decimal("10"), max_hold_candles=100, fee_pct=0.0, slippage_pct=0.0, + ) + + # Nothing closes it early — it rides to END_OF_DATA. + assert len(trades) == 1 + assert trades[0]["exit_reason"] == "END_OF_DATA" + + def test_scores_are_causal_future_prices_dont_change_earlier_scores(): """Regression test for the look-ahead fix: market_structure() (SMC) and detect_divergence() used to be precomputed ONCE over the whole candle diff --git a/backend/tests/test_indicator_service.py b/backend/tests/test_indicator_service.py index 520e60d..35f1b43 100644 --- a/backend/tests/test_indicator_service.py +++ b/backend/tests/test_indicator_service.py @@ -23,8 +23,12 @@ from app.services.indicator_service import ( rsi, sma, vwap, + _adaptive_volatile_threshold, _find_pivot_highs, _find_pivot_lows, + _DEFAULT_VOLATILE_THRESHOLD_PCT, + _MIN_VOLATILE_THRESHOLD_PCT, + _MAX_VOLATILE_THRESHOLD_PCT, ) @@ -269,6 +273,76 @@ class TestDetectMarketRegime: assert regime == "neutral" +class TestAdaptiveVolatileThreshold: + """Regression tests for fix (kk): a fixed `atr_pct > 5.0` cutoff + misclassifies most symbols — BTC/ETH on 15m rarely exceed 1-2% ATR (so + "volatile" would almost never fire), while a thin-liquidity altcoin + routinely trades 5%+ as its NORMAL state (so it would almost always be + flagged "volatile"). `_adaptive_volatile_threshold` instead judges a + symbol against its own recent ATR% history. + """ + + def test_no_history_falls_back_to_default(self): + assert _adaptive_volatile_threshold(None) == _DEFAULT_VOLATILE_THRESHOLD_PCT + assert _adaptive_volatile_threshold([]) == _DEFAULT_VOLATILE_THRESHOLD_PCT + + def test_insufficient_history_falls_back_to_default(self): + # Fewer than 20 valid points — not enough to trust a percentile. + assert _adaptive_volatile_threshold([1.0] * 10) == _DEFAULT_VOLATILE_THRESHOLD_PCT + + def test_calm_major_gets_a_lower_threshold_than_the_fixed_default(self): + """A BTC-like symbol whose ATR% is normally 0.5-2% should get a + threshold well under the old fixed 5.0% — otherwise "volatile" + could effectively never fire for it.""" + history = [0.5 + 0.05 * (i % 30) for i in range(60)] # ~0.5%-2.0% + threshold = _adaptive_volatile_threshold(history) + assert threshold < _DEFAULT_VOLATILE_THRESHOLD_PCT + assert threshold >= _MIN_VOLATILE_THRESHOLD_PCT + + def test_naturally_volatile_altcoin_gets_a_higher_threshold_than_the_fixed_default(self): + """A memecoin/thin-liquidity symbol whose ATR% is normally 8-12% + should get a threshold well over the old fixed 5.0% — otherwise it + would be flagged "volatile" almost every candle.""" + history = [8.0 + 0.1 * (i % 40) for i in range(60)] # ~8%-12% + threshold = _adaptive_volatile_threshold(history) + assert threshold > _DEFAULT_VOLATILE_THRESHOLD_PCT + assert threshold <= _MAX_VOLATILE_THRESHOLD_PCT + + def test_threshold_never_drops_below_the_floor(self): + history = [0.01] * 30 # near-motionless + assert _adaptive_volatile_threshold(history) == _MIN_VOLATILE_THRESHOLD_PCT + + def test_threshold_never_exceeds_the_ceiling(self): + history = [80.0] * 30 # a data glitch or extreme outlier symbol + assert _adaptive_volatile_threshold(history) == _MAX_VOLATILE_THRESHOLD_PCT + + def test_none_values_in_history_are_ignored(self): + history = [None] * 5 + [1.0 + 0.02 * i for i in range(25)] + threshold = _adaptive_volatile_threshold(history) + assert threshold != _DEFAULT_VOLATILE_THRESHOLD_PCT # enough valid points to compute a real percentile + + def test_detect_market_regime_uses_adaptive_threshold_not_fixed_default(self): + """The same atr_pct=6.0 is 'volatile' under the fixed default (>5%) + but must NOT be under an altcoin's own history where 6% is + unremarkable — this is the exact behavior fix (kk) changes.""" + bb = {"upper": [], "lower": []} + volatile_altcoin_history = [8.0 + 0.1 * (i % 40) for i in range(60)] + + regime_without_history = detect_market_regime( + self._adx(15), bb, atr_pct=6.0, volume_data=None, + ) + regime_with_history = detect_market_regime( + self._adx(15), bb, atr_pct=6.0, volume_data=None, + atr_pct_history=volatile_altcoin_history, + ) + + assert regime_without_history == "volatile" + assert regime_with_history != "volatile" + + def _adx(self, value): + return {"adx": [value]} + + class TestPivotCausalConsistency: """A pivot at index i is only knowable once `right` bars after it exist (see `_find_pivot_highs`/`_find_pivot_lows`'s definition). Live trading diff --git a/backend/tests/test_signal_scoring.py b/backend/tests/test_signal_scoring.py index 32967f6..64fa48c 100644 --- a/backend/tests/test_signal_scoring.py +++ b/backend/tests/test_signal_scoring.py @@ -8,6 +8,8 @@ from __future__ import annotations import math from decimal import Decimal +import pytest + from app.services.signal_scoring import ( BUY, CAUTION_LONG, @@ -20,10 +22,12 @@ from app.services.signal_scoring import ( _calculate_pnl, _classify_signal_bb, _classify_signal_combined, + _compute_adjusted_score, _detect_squeeze, _get_bb_values, _get_rsi_values, _get_sma_values, + _pair_correlation, ) @@ -227,6 +231,64 @@ class TestClassifySignalCombined: assert (signal_type, strength, confidence) == (CAUTION_SHORT, "MODERATE", 0.5) +class TestPairwiseCorrelationDampening: + """Regression tests for fix (jj): correlation dampening used to treat + every co-active pair within a hand-picked group as equally, fully + correlated (uniform 1/sqrt(count)) — e.g. dampening double_bb_rsi + identically whether it fired alongside StochRSI (near-duplicate of + RSI, correlation ~0.85) or MFI (volume-weighted, more complementary, + ~0.5). Now dampening scales with the actual pairwise correlation. + """ + + NO_SQUEEZE_BB = {"upper": [110], "lower": [90], "upper_1": [105], "lower_1": [95], "middle": [100]} + + def test_pair_correlation_known_pairs(self): + assert _pair_correlation("double_bb_rsi", "stoch_rsi") == pytest.approx(0.85) + assert _pair_correlation("double_bb_rsi", "mfi") == pytest.approx(0.5) + # Order-independent (frozenset-keyed). + assert _pair_correlation("stoch_rsi", "double_bb_rsi") == pytest.approx(0.85) + + def test_pair_correlation_unknown_pair_falls_back_to_default(self): + assert _pair_correlation("nonexistent_a", "nonexistent_b") == pytest.approx(0.5) + + def test_high_correlation_partner_dampens_harder_than_low_correlation_partner(self): + """Same double_bb_rsi vote (2.0, from a lone strong BB signal — see + TestClassifySignalCombined.test_single_strong_bb_vote_alone_only_ + reaches_moderate_buy), paired once with StochRSI and once with MFI + voting the identical pre-dampening magnitude (1.5) in the same + direction — any difference in double_bb_rsi's post-dampening value + is purely from the correlation coefficient, not the partner's vote + strength. Dampening is symmetric, so the partner's own displayed + vote is dampened too (by the same pairwise coefficient) — that's + expected, not a separate bug. + """ + common_kwargs = dict( + close_price=115.0, bb=self.NO_SQUEEZE_BB, rsi=[65], sma=[102], + macd_data=None, st_data=None, vol_data=None, + ) + + _, _, _, _, raw_with_stoch_rsi = _compute_adjusted_score( + **common_kwargs, stoch_rsi_data={"k": [10, 17], "d": [15, 15]}, + ) + _, _, _, _, raw_with_mfi = _compute_adjusted_score( + **common_kwargs, mfi_data=[10, 15], + ) + + # Exact expected values: dampen = 1/sqrt(1 + corr). + assert raw_with_stoch_rsi["stoch_rsi"] == pytest.approx(1.5 / math.sqrt(1.85)) + assert raw_with_mfi["mfi"] == pytest.approx(1.5 / math.sqrt(1.5)) + assert raw_with_stoch_rsi["double_bb_rsi"] < raw_with_mfi["double_bb_rsi"] + assert raw_with_stoch_rsi["double_bb_rsi"] == pytest.approx(2.0 / math.sqrt(1.85)) + assert raw_with_mfi["double_bb_rsi"] == pytest.approx(2.0 / math.sqrt(1.5)) + + def test_no_same_sign_partner_leaves_score_undampened(self): + _, _, _, _, raw_scores = _compute_adjusted_score( + close_price=115.0, bb=self.NO_SQUEEZE_BB, rsi=[65], sma=[102], + macd_data=None, st_data=None, vol_data=None, + ) + assert raw_scores["double_bb_rsi"] == pytest.approx(2.0) + + class TestApplyRegimeFilter: def test_non_directional_signal_passes_through_for_any_regime(self): for regime in ("choppy", "sideways", "volatile", "trending", "neutral", None): diff --git a/backend/tests/test_trade_executor.py b/backend/tests/test_trade_executor.py index a850782..a735f2f 100644 --- a/backend/tests/test_trade_executor.py +++ b/backend/tests/test_trade_executor.py @@ -156,6 +156,55 @@ async def test_dedup_skips_when_same_direction_already_open(db_session): assert trades[0].status == "OPEN" +async def test_opening_a_trade_locks_the_user_row_before_reading_open_count(db_session, monkeypatch): + """Regression test for fix (ll): a per-user SELECT ... FOR UPDATE on + the `users` row must be issued, and BEFORE the open-trade-count query + — this is what forces two concurrent execute_signal_trade() calls for + the same user (e.g. two STRONG signals for different symbols arriving + at nearly the same time) to serialize instead of both reading the same + stale open_count and both overshooting MAX_OPEN_TRADES (a phantom read + that row-locking the *existing* open trades alone can't prevent, since + neither transaction's lock covers a row that doesn't exist yet). + + True concurrent-transaction blocking can't be exercised against the + in-memory SQLite test DB (single connection, no real MVCC locking) — + this instead asserts the lock statement is actually issued, and in the + right order relative to the existing open-trades lock query. + """ + user = make_user() + db_session.add(user) + await db_session.flush() + signal = make_signal(STRONG_BUY, symbol="BTC/USDT") + db_session.add(signal) + await db_session.flush() + + executed_statements = [] + real_execute = db_session.execute + + async def spy_execute(stmt, *args, **kwargs): + executed_statements.append(stmt) + return await real_execute(stmt, *args, **kwargs) + + monkeypatch.setattr(db_session, "execute", spy_execute) + + await execute_signal_trade(db_session, signal, "BTC/USDT", "mexc", "1h", Decimal("50000")) + + user_lock_idx = None + open_trades_lock_idx = None + for idx, stmt in enumerate(executed_statements): + sql = str(stmt) + if "FOR UPDATE" not in sql.upper(): + continue + if "FROM users" in sql: + user_lock_idx = idx + elif "FROM hypothetical_trades" in sql: + open_trades_lock_idx = idx + + assert user_lock_idx is not None, "expected a SELECT ... FOR UPDATE on the users table" + assert open_trades_lock_idx is not None, "expected the existing SELECT ... FOR UPDATE on hypothetical_trades" + assert user_lock_idx < open_trades_lock_idx, "the user row must be locked BEFORE reading the open-trade count" + + async def test_strong_reversal_closes_opposite_trade_and_opens_new_one(db_session): user = make_user() db_session.add(user) @@ -301,6 +350,106 @@ async def test_hybrid_eviction_uses_each_trades_own_symbol_price_not_incoming_si assert any(t.symbol == "NEW/USDT" and t.status == "OPEN" for t in all_trades) +class TestKellyPortfolioCorrelationDampening: + """Regression tests for fix (hh): Kelly sizing used to size every new + trade as if it were the only position in the book, ignoring that + crypto altcoins are typically highly correlated — a book full of + same-direction positions carries much more compounded risk on a + market-wide move than per-trade Kelly fractions summed naively would + suggest. Same-direction open positions should now dampen sizing by + 1/sqrt(n+1); opposite-direction ones (which net against that risk) + should not. + """ + + def _patch_kelly_inputs(self, monkeypatch): + import app.services.signal_booster as signal_booster_module + + # High edge + full confidence so the Kelly-adjusted trade_size lands + # well clear of both the $5 floor and $500 cap in trade_executor.py + # — otherwise those floors/caps would mask the dampening effect + # this test is actually trying to observe. + async def fake_rates(): + return {"__all__": 0.9, "__all___LONG": 0.9, "__all___SHORT": 0.9} + + async def fake_pnl_stats(): + return {"avg_win": 5.0, "avg_loss": 1.0} + + monkeypatch.setattr(signal_booster_module, "get_cached_rates", fake_rates) + monkeypatch.setattr(signal_booster_module, "get_pnl_stats", fake_pnl_stats) + + def _confident_signal(self, symbol="BTC/USDT"): + return Signal( + symbol=symbol, exchange="mexc", timeframe="1h", + signal_type=STRONG_BUY, strength="STRONG", price=Decimal("100"), + timestamp=datetime.now(timezone.utc), + indicators_snapshot=json.dumps({"confidence": 1.0}), + ) + + async def test_more_same_direction_open_positions_shrinks_new_trade_size(self, db_session, monkeypatch): + self._patch_kelly_inputs(monkeypatch) + + baseline_user = make_user(trade_size=200) + crowded_user = make_user(trade_size=200) + db_session.add_all([baseline_user, crowded_user]) + await db_session.flush() + + # crowded_user already has 3 open LONG positions (different symbols + # — dedup/eviction only look at the incoming signal's own symbol + # and MAX_OPEN_TRADES=10 respectively, so these don't interfere). + for i in range(3): + db_session.add(HypotheticalTrade( + user_id=crowded_user.id, symbol=f"OTHER{i}/USDT", exchange="mexc", timeframe="1h", + direction="LONG", entry_price=Decimal("100"), + entry_time=datetime.now(timezone.utc), quantity=Decimal("1"), status="OPEN", + )) + await db_session.flush() + + signal_baseline = self._confident_signal("BTC/USDT") + signal_crowded = self._confident_signal("BTC/USDT") + db_session.add_all([signal_baseline, signal_crowded]) + await db_session.flush() + + await execute_signal_trade(db_session, signal_baseline, "BTC/USDT", "mexc", "1h", Decimal("50000")) + await execute_signal_trade(db_session, signal_crowded, "BTC/USDT", "mexc", "1h", Decimal("50000")) + + baseline_trade = (await _open_trades_for(db_session, baseline_user.id, "BTC/USDT"))[0] + crowded_trade = (await _open_trades_for(db_session, crowded_user.id, "BTC/USDT"))[0] + + # 3 existing same-direction positions -> dampen by 1/sqrt(3+1) = 0.5. + assert float(crowded_trade.quantity) == pytest.approx(float(baseline_trade.quantity) * 0.5, rel=1e-6) + + async def test_opposite_direction_open_positions_do_not_dampen_sizing(self, db_session, monkeypatch): + self._patch_kelly_inputs(monkeypatch) + + baseline_user = make_user(trade_size=200) + hedged_user = make_user(trade_size=200) + db_session.add_all([baseline_user, hedged_user]) + await db_session.flush() + + # hedged_user has 3 open SHORT positions — opposite direction to + # the incoming LONG signal, so they must NOT dampen sizing. + for i in range(3): + db_session.add(HypotheticalTrade( + user_id=hedged_user.id, symbol=f"OTHER{i}/USDT", exchange="mexc", timeframe="1h", + direction="SHORT", entry_price=Decimal("100"), + entry_time=datetime.now(timezone.utc), quantity=Decimal("1"), status="OPEN", + )) + await db_session.flush() + + signal_baseline = self._confident_signal("BTC/USDT") + signal_hedged = self._confident_signal("BTC/USDT") + db_session.add_all([signal_baseline, signal_hedged]) + await db_session.flush() + + await execute_signal_trade(db_session, signal_baseline, "BTC/USDT", "mexc", "1h", Decimal("50000")) + await execute_signal_trade(db_session, signal_hedged, "BTC/USDT", "mexc", "1h", Decimal("50000")) + + baseline_trade = (await _open_trades_for(db_session, baseline_user.id, "BTC/USDT"))[0] + hedged_trade = (await _open_trades_for(db_session, hedged_user.id, "BTC/USDT"))[0] + + assert float(hedged_trade.quantity) == pytest.approx(float(baseline_trade.quantity), rel=1e-6) + + def make_real_trade(user_id, symbol="BTC/USDT", side="buy", amount="1", price="100", filled_amount=None, status="filled", created_at=None) -> RealTrade: return RealTrade( diff --git a/theo_doi_trading-portal_v11.md b/theo_doi_trading-portal_v11.md new file mode 100644 index 0000000..80427eb --- /dev/null +++ b/theo_doi_trading-portal_v11.md @@ -0,0 +1,121 @@ +# Theo dõi đánh giá dự án Trading Portal — v11 + +> **Ngày đánh giá gốc:** 2026-07-03 +> **Cập nhật v1-v10:** xem các file `theo_doi_trading-portal_v1.md`…`v10.md` +> **Cập nhật v11 (lần này):** 2026-07-04 — triển khai 5 vấn đề tiếp theo từ danh sách tồn đọng v10 (mục 4): (ii) SL/TP theo ATR trong backtest, (hh) Kelly sizing tính correlation danh mục, (jj) dampening theo cặp tương quan thực tế, (kk) ngưỡng volatile thích ứng theo từng symbol, (ll) sửa race condition trong eviction +> **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_v12.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 + +Tiếp nối v10 (mô phỏng phí/slippage, PnL thật FIFO, xác nhận độ trễ pivot), người dùng chọn tiếp 5 vấn đề ưu tiên cao từ danh sách tồn đọng để sửa theo đúng thứ tự: **(ii) → (hh) → (jj) → (kk) → (ll)**. Phạm vi của (ii) được thu hẹp qua thảo luận với người dùng: chỉ làm phần SL/TP theo ATR trong backtest (tractable, không đổi kiến trúc single-symbol), **không** xây dựng portfolio backtest engine đa symbol để mô phỏng eviction đa vị thế (việc đó được xác nhận là một tính năng lớn, để dành cho quyết định riêng sau này nếu cần). + +--- + +## 3. Các thay đổi trong v11 + +### 3.1 (ii) SL/TP theo ATR/regime trong backtest_engine.py + +**Vấn đề:** Backtest/walk-forward trước đây chỉ thoát lệnh qua REVERSAL/TIME_LIMIT/END_OF_DATA — dễ dãi hơn nhiều so với live trading, vốn còn cắt lệnh qua STOP_LOSS/TAKE_PROFIT tính theo ATR + market regime (`AdaptiveSLTPOptimizer` trong `risk_manager.py`, dùng bởi `signal_service.py`'s `close_stale_trades()`). Kết quả walk-forward trước đây không phản ánh đúng cơ chế rủi ro quan trọng nhất mà live trading thực sự dùng. + +**Đã sửa** (`backend/app/services/backtest_engine.py`): +- Hàm `_effective_sl_tp_pct()` mới: tính % khoảng cách SL/TP theo ATR hiện tại + regime của nến đó, dùng đúng `AdaptiveSLTPOptimizer.compute_sl_tp()` và 2 ngưỡng sàn (floor) `3%`/`6%` y hệt `close_stale_trades()` áp dụng cho live. +- `_compute_scores_series()` giờ lưu thêm `"atr"` (giá trị ATR tuyệt đối tại mỗi nến) vào mỗi phần tử của `scores_series`. +- `_simulate_from_scores()`: mỗi nến có vị thế đang mở, kiểm tra thêm STOP_LOSS/TAKE_PROFIT dựa trên `_effective_sl_tp_pct()` (dùng entry_price cố định + ATR/regime hiện tại của nến đó) **trước khi** kiểm tra TIME_LIMIT — đúng thứ tự ưu tiên rủi ro. +- Không có tham số bật/tắt: SL/TP theo ATR luôn được áp dụng mặc định, vì mục tiêu là kết quả backtest phản ánh đúng cái live thực sự làm, không phải một lựa chọn tùy chọn. + +**Test mới** (`tests/test_backtest_engine.py`, class `TestAtrStopLossTakeProfit`): `_effective_sl_tp_pct` dùng đúng ATR×regime-multiplier và áp dụng sàn khi ATR ngụ ý mức di chuyển quá nhỏ; fallback về mặc định 5%/10% khi thiếu ATR; test tích hợp xác nhận STOP_LOSS đóng lệnh LONG khi giá giảm vượt ngưỡng, TAKE_PROFIT đóng khi giá tăng vượt ngưỡng, SHORT cũng đối xứng đúng hướng; di chuyển giá nhỏ (trong ngưỡng) không kích hoạt, để lệnh chạy tới END_OF_DATA như trước. + +**Lưu ý quan trọng để tests khác không bị hỏng:** SL/TP theo ATR áp dụng cho MỌI test đã có từ trước gọi `_simulate_trades`/`_simulate_from_scores` — vài test cũ dùng chuỗi giá tuyến tính (`lambda i: i`) với biên độ lớn giữa lúc mở và đóng lệnh (test cho REVERSAL/TIME_LIMIT) đã phải điều chỉnh lại (rút ngắn khoảng cách nến hoặc giảm `max_hold_candles`) để đảm bảo đang kiểm tra đúng exit-path dự kiến, không bị TAKE_PROFIT "chen ngang" trước. Một test dùng chuỗi giá hoàn toàn phẳng đã lộ ra rằng `detect_market_regime()` phân loại giá phẳng tuyệt đối là **"choppy"** (đúng — hợp lý về mặt định nghĩa), khiến tín hiệu bị `_apply_regime_filter` chặn hoàn toàn; test đã đổi sang dùng chuỗi giá tăng dần (trending, không choppy) để tránh nhầm lẫn 2 cơ chế khác nhau. + +### 3.2 (hh) Kelly sizing tính correlation giữa các vị thế đang mở + +**Vấn đề:** `DynamicKellySizer.compute_kelly_pct()` tính size cho mỗi lệnh mới độc lập, không biết có bao nhiêu lệnh khác đang mở cùng chiều — trong khi crypto altcoin thường tương quan cao với nhau (đặc biệt khi BTC biến động mạnh), nên một sổ lệnh toàn LONG (hoặc toàn SHORT) gánh rủi ro cộng dồn lớn hơn nhiều so với tổng Kelly% từng lệnh riêng lẻ. + +**Đã sửa** (`backend/app/services/trade_executor.py`): sau khi tính `kelly_pct` như cũ, nhân thêm hệ số giảm chấn `1/sqrt(same_direction_open_count + 1)`, trong đó `same_direction_open_count` là số lệnh đang mở **cùng chiều** với tín hiệu mới (đếm từ `all_open_trades` đã fetch sẵn cho bước eviction). Lệnh đang mở **ngược chiều** (tự nhiên phòng hộ rủi ro thị trường) không làm giảm size — chỉ số lệnh cùng chiều mới cộng dồn rủi ro thực sự. Công thức giống hệt kiểu dampening `1/sqrt(n)` đã dùng cho hệ thống vote 13 thuật toán (mục 3.3), nhất quán trong toàn hệ thống. + +**Test mới** (`tests/test_trade_executor.py`, class `TestKellyPortfolioCorrelationDampening`): 3 lệnh LONG đang mở (symbol khác) → lệnh LONG mới bị giảm size đúng hệ số `1/sqrt(4)=0.5`; 3 lệnh SHORT đang mở (ngược chiều với tín hiệu LONG mới) → size KHÔNG bị giảm. + +### 3.3 (jj) Dampening theo cặp tương quan thực tế thay vì đồng đều + +**Vấn đề:** Cơ chế "correlation dampening" cũ (`1/sqrt(count)`) coi mọi thành viên cùng nhóm là tương quan đầy đủ như nhau — trong khi StochRSI về bản chất là hàm trực tiếp của RSI (tương quan thực tế >0.8), còn MFI (RSI có trọng số volume) tương quan thấp hơn nhiều (~0.5) với cả hai. Dampening đồng đều làm giảm quá mức tín hiệu bổ trợ thực sự của MFI trong khi giảm chưa đủ với cặp gần-trùng-lặp RSI/StochRSI. + +**Đã sửa** (`backend/app/services/signal_scoring.py`): thay công thức `1/sqrt(pos_count)` bằng `1/sqrt(1 + Σcorrelation(s,t))` cho từng thuật toán `s`, tính tổng hệ số tương quan với TẤT CẢ đối tác cùng dấu đang hoạt động trong cùng nhóm. Bảng `_PAIR_CORRELATION_WEIGHTS` liệt kê hệ số cho từng cặp cụ thể (double_bb_rsi-stoch_rsi=0.85, double_bb_rsi-mfi=0.5, macd_crossover-supertrend=0.7, volume_breakout-obv=0.4, v.v. — xem code để đầy đủ), là ước lượng dựa trên hiểu biết domain, **chưa được hiệu chỉnh bằng dữ liệu lịch sử thực tế của hệ thống** (việc đó để dành cho vòng sau nếu cần độ chính xác cao hơn). Công thức tương đương chính xác với công thức cũ khi hệ số tương quan giả định = 1.0 cho mọi cặp — tức là công thức mới tổng quát hóa, không phải thay thế hoàn toàn logic cũ. + +**Test mới** (`tests/test_signal_scoring.py`, class `TestPairwiseCorrelationDampening`): xác nhận `_pair_correlation` trả đúng hệ số theo cặp (không phụ thuộc thứ tự tham số); cùng một vote `double_bb_rsi` (2.0) khi đi kèm StochRSI bị dampen MẠNH HƠN khi đi kèm MFI cùng biên độ vote — đúng chính xác giá trị kỳ vọng theo công thức `1/sqrt(1+corr)`; không có đối tác cùng dấu → không bị dampen. + +### 3.4 (kk) Ngưỡng "volatile" thích ứng theo từng symbol + +**Vấn đề:** `detect_market_regime()` dùng ngưỡng cố định `atr_pct > 5.0` để phân loại "volatile" cho MỌI symbol — BTC/ETH khung 15m hiếm khi vượt 1-2% ATR (nên "volatile" gần như không bao giờ kích hoạt cho major coin), trong khi một altcoin thanh khoản mỏng thường xuyên vượt 5% NHƯ MỘT TRẠNG THÁI BÌNH THƯỜNG (nên gần như luôn bị gắn nhãn "volatile", downgrade/suppress tín hiệu quá mức). + +**Đã sửa** (`backend/app/services/indicator_service.py`): hàm `_adaptive_volatile_threshold()` mới — tính ngưỡng "volatile" là **percentile thứ 90** của lịch sử ATR% gần đây của CHÍNH symbol đó, thay vì một con số cố định dùng chung. Có sàn (`1.0%`) và trần (`15.0%`) để tránh cực đoan, và fallback về ngưỡng mặc định `5.0%` khi chưa đủ dữ liệu lịch sử (symbol mới, cache lạnh). `detect_market_regime()` nhận thêm tham số tùy chọn `atr_pct_history`. +- `candle_service.py` (live trading): tính toàn bộ chuỗi ATR% từ dữ liệu đã fetch cho symbol/timeframe đó, truyền vào `atr_pct_history`. +- `backtest_engine.py`: precompute `atr_pct_full` một lần cho toàn bộ dữ liệu backtest, mỗi nến truyền một cửa sổ trượt 100 nến gần nhất (bounded, giữ đúng tính chất O(n) không phải O(n²) của toàn bộ module) làm `atr_pct_history`. + +**Test mới** (`tests/test_indicator_service.py`, class `TestAdaptiveVolatileThreshold`): thiếu lịch sử → fallback mặc định 5.0%; symbol "giống BTC" (ATR% lịch sử 0.5-2%) → ngưỡng thấp hơn 5.0% hẳn; symbol "giống altcoin" (ATR% lịch sử 8-12%) → ngưỡng cao hơn 5.0% hẳn; sàn/trần hoạt động đúng ở 2 cực; test tích hợp xác nhận cùng một `atr_pct=6.0` là "volatile" khi không có lịch sử (ngưỡng mặc định 5%) nhưng KHÔNG "volatile" khi có lịch sử của một altcoin biến động cao (6% là bình thường với nó) — đây chính xác là hành vi fix (kk) thay đổi. + +### 3.5 (ll) Sửa race condition trong eviction logic + +**Vấn đề:** `execute_signal_trade()` dùng `.with_for_update()` trên các dòng `HypotheticalTrade` đang mở để tính `open_count` trước khi quyết định eviction — nhưng khóa dòng (row lock) chỉ bảo vệ các dòng ĐÃ TỒN TẠI, không bảo vệ được **phantom read**: nếu 2 tín hiệu STRONG cho 2 symbol khác nhau đến gần như đồng thời cho CÙNG một user, cả 2 transaction có thể cùng khóa đúng tập dòng hiện có, nhưng không transaction nào biết về dòng MỚI mà transaction kia sắp INSERT — cả 2 đọc cùng `open_count`, cả 2 vượt qua kiểm tra `MAX_OPEN_TRADES`, cả 2 cùng mở lệnh mới, vượt giới hạn tạm thời. + +**Đã sửa** (`backend/app/services/trade_executor.py`): thêm `SELECT ... FOR UPDATE` khóa **chính dòng `User`** (fetch mới, không dùng lại đối tượng `first_user` có thể đã cũ) ngay TRƯỚC bước đọc `open_count`/eviction. Vì cả 2 transaction đồng thời sẽ cùng cố khóa đúng 1 dòng `users` giống hệt nhau, transaction thứ 2 buộc phải CHỜ transaction thứ 1 commit xong mới được tiếp tục — lúc đó nó mới đọc `open_count` mới nhất (đã bao gồm lệnh transaction 1 vừa mở), loại bỏ hoàn toàn cửa sổ race. Cách này hoạt động cả trên PostgreSQL (production) lẫn SQLite (test suite, vì `.with_for_update()` được SQLAlchemy compile bỏ qua an toàn trên SQLite thay vì lỗi). + +**Test mới** (`tests/test_trade_executor.py`): do SQLite in-memory dùng 1 connection duy nhất (không có MVCC/lock thật để mô phỏng race concurrency thực sự), test xác nhận đúng câu lệnh `SELECT ... FOR UPDATE` trên bảng `users` THỰC SỰ được phát ra, và đúng thứ tự — TRƯỚC câu lệnh khóa `hypothetical_trades` đã có sẵn — bằng cách spy vào `db.execute`. + +### 3.6 Kiểm chứng + +Toàn bộ 209 test backend pass (tăng từ 187 ở v10 lên 209 — thêm 6 test SL/TP theo ATR, 2 test Kelly correlation, 4 test pairwise dampening, 7 test adaptive volatile threshold, 1 test lock ordering, cộng điều chỉnh 3 test cũ trong `test_backtest_engine.py` bị ảnh hưởng bởi việc thêm SL/TP luôn-bật). Không đổi gì ở frontend trong đợt này. + +--- + +## 4. Danh sách vấn đề còn tồn đọng — ưu tiên cho vòng tiếp theo + +Đã hoàn tất toàn bộ 5 mục Ưu tiên CAO của v10 (hh, ii, jj, kk, ll). Còn lại: + +### Ưu tiên TRUNG BÌNH (chưa đổi từ v10) +| # | Vấn đề | Vị trí | +|---|---|---| +| mm | Kelly sizing dùng win-rate tổng hợp `__all__` thay vì theo symbol/strategy cụ thể | `trade_executor.py:272-273` | +| nn | `compute_partial_tp_levels` thiếu 40% khối lượng "trailing" như docstring hứa hẹn (chỉ trả về 60%) | `risk_manager.py:169-203` | +| oo | `AdaptiveSLTPOptimizer`/`compute_volatility_adjusted_size` — `compute_volatility_adjusted_size` cụ thể vẫn có vẻ là code mồ côi (compute_sl_tp giờ ĐÃ được dùng ở cả live lẫn backtest sau mục 3.1, nhưng size-theo-volatility riêng vẫn chưa thấy nơi gọi) | `risk_manager.py` | +| pp | Ichimoku dùng nguyên bộ 9-26-52-26 của lịch giao dịch Nhật cổ, không có cơ sở cho crypto 24/7 | `indicator_service.py:583-599` | +| qq | FVG thiếu bộ lọc kích thước gap theo ATR — sinh nhiều tín hiệu nhiễu ở khung 15m | `indicator_service.py:1334-1377` | +| rr | `MIN_TRADES_PER_FOLD=5` hơi thấp cho ý nghĩa thống kê Sharpe (khuyến nghị quant thường ≥30) | `walk_forward.py:48` | +| ss | Volatility filter và Kelly sizing bọc trong `except: pass` quá rộng, lỗi bị nuốt âm thầm trong production | `trade_executor.py` | + +### Ưu tiên THẤP (chưa đổi từ v10) +| # | Vấn đề | Vị trí | +|---|---|---| +| tt | Toàn bộ tham số kinh điển của 13 thuật toán (RSI-14, MACD 12-26-9, BB-20, SuperTrend 10/3.0...) là giá trị sách giáo khoa cho cổ phiếu/forex daily, chưa qua walk-forward riêng cho crypto | `indicator_service.py` (toàn bộ) | +| uu | BOS buffer 0.3% cố định nên đổi theo ATR thay vì % tuyệt đối | `indicator_service.py:760,762` | +| vv | Order Block detection quá đơn giản (thiếu điều kiện volume/imbalance) | `indicator_service.py:805-851` | +| ww | Ngưỡng candlestick pattern (Doji/Marubozu/Hammer...) chưa kiểm định theo winrate thực tế trên crypto | `indicator_service.py:1432-1497` | +| xx | Fallback grid search khi mọi combo đều `-inf` chọn tùy ý phần tử đầu grid thay vì combo "ít tệ nhất" | `walk_forward.py:178-183` | + +### Đã xác nhận là scope-lớn, cần quyết định riêng (không phải bug) +| # | Vấn đề | Ghi chú | +|---|---|---| +| yy | Backtest chỉ mô phỏng 1 vị thế/symbol — chưa mô phỏng eviction đa vị thế (MAX_OPEN_TRADES=10) qua nhiều symbol đồng thời | Cần một portfolio-backtest engine mới chạy nhiều symbol song song + 1 pool vốn chung — người dùng đã xác nhận KHÔNG làm trong v11, để dành quyết định riêng nếu cần trong tương lai | + +### Ghi chú về hiệu chỉnh dữ liệu (không phải bug, nhưng đáng lưu ý) +Các hệ số trong `_PAIR_CORRELATION_WEIGHTS` (mục 3.3) là ước lượng theo hiểu biết domain (StochRSI~RSI cao, MACD~SuperTrend trung bình-cao, v.v.), **chưa được đo đạc bằng correlation matrix thực tế từ lịch sử tín hiệu của chính hệ thống này**. Nếu muốn độ chính xác cao hơn, bước tiếp theo hợp lý là: thu thập `algo_scores` (đã có sẵn trong `indicators_snapshot` của mỗi `Signal`) qua một giai đoạn đủ dài, tính ma trận tương quan Pearson thực tế giữa 13 chuỗi điểm số, rồi thay các hệ số ước lượng hiện tại bằng số đo được. + +--- + +## 5. Lịch sử phiên bản + +| Phiên bản | Ngày | Thay đổi | +|---|---|---| +| v0-v7 | 2026-07-03 — 2026-07-04 | Xem file tương ứng | +| v8 | 2026-07-04 | Wire ADX/regime vào bộ lọc tín hiệu dùng chung, hợp nhất 2 hệ thống regime. 170 test pass. | +| v9 | 2026-07-04 | Sửa bug CSS toàn cục: reset viết tay không nằm trong `@layer` vô hiệu hóa toàn bộ margin/padding utility Tailwind app-wide. 170 test pass. | +| v10 | 2026-07-04 | Audit 13 thuật toán + toàn hệ thống risk/execution. Mô phỏng phí+slippage trong backtest/WFO, FIFO PnL thật cho real trades, xác nhận độ trễ pivot Divergence/SMC đã đúng từ trước. 187 test pass (+17). | +| v11 | 2026-07-04 | Triển khai 5 vấn đề ưu tiên cao còn lại: (ii) SL/TP theo ATR trong backtest, (hh) Kelly correlation dampening theo danh mục, (jj) correlation dampening theo cặp thực tế thay vì đồng đều, (kk) ngưỡng volatile thích ứng theo symbol, (ll) sửa race condition trong eviction (lock dòng User). 209 test pass (+22). Portfolio backtest đa vị thế (yy) xác nhận là scope lớn, để dành quyết định riêng. | \ No newline at end of file