feat: ATR-based SL/TP in backtest, portfolio-aware Kelly sizing, weighted correlation dampening, adaptive volatile threshold, fix eviction race

- backtest_engine.py: positions now also exit on ATR/regime-adaptive
  STOP_LOSS/TAKE_PROFIT (mirroring signal_service.py's close_stale_trades),
  not just REVERSAL/TIME_LIMIT/END_OF_DATA -- backtest/WFO now exercises
  the same exit rule live trading actually enforces.
- trade_executor.py: Kelly sizing dampens by 1/sqrt(same_direction_open+1)
  to account for correlated risk across simultaneously open positions
  (crypto altcoins move together); opposite-direction positions don't
  dampen since they net against that risk.
- signal_scoring.py: correlation dampening between the 13 vote algorithms
  now uses per-pair weighted coefficients (StochRSI~RSI high, MFI~RSI
  moderate, etc.) instead of uniform 1/sqrt(count), so near-duplicate
  signals get dampened harder than genuinely complementary ones.
- indicator_service.py: "volatile" regime threshold is now the 90th
  percentile of a symbol's own recent ATR% history instead of one fixed
  5% cutoff shared by every symbol (BTC vs. a naturally-volatile altcoin).
- trade_executor.py: fixed a phantom-read race in the eviction path where
  two concurrent signals for the same user could both pass the
  MAX_OPEN_TRADES check before either committed -- now locks the user row
  first to serialize per-user trade-opening.

209 backend tests pass (+22).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 16:58:37 +07:00
parent 662586c6bc
commit cccef51eaf
10 changed files with 747 additions and 42 deletions
+74 -1
View File
@@ -33,6 +33,7 @@ from app.services.signal_scoring import (
_apply_regime_filter, _apply_regime_filter,
STRONG_BUY, BUY, STRONG_SELL, SELL, 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 for warmup: BB(20) + RSI(14) + some room = 30
MIN_CANDLES = 30 MIN_CANDLES = 30
@@ -48,6 +49,38 @@ MIN_CANDLES = 30
DEFAULT_TAKER_FEE_PCT = 0.001 DEFAULT_TAKER_FEE_PCT = 0.001
DEFAULT_SLIPPAGE_PCT = 0.0005 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: 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 """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 # bounded tail, not the whole growing history, for the same O(n), not
# O(n^2), reason as everything else in this module. # O(n^2), reason as everything else in this module.
_REGIME_WINDOW = 30 _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 # SMC (market_structure) and divergence detection are built from pivot
# points (local highs/lows), not rolling windows — the old approach # points (local highs/lows), not rolling windows — the old approach
# precomputed a single current-state snapshot (bos/choch/trend/ # 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. # set every one of the 13 algorithms would produce unfiltered.
adx_full = adx(candle_dicts_full, period=14) or {"adx": [], "plus_di": [], "minus_di": []} adx_full = adx(candle_dicts_full, period=14) or {"adx": [], "plus_di": [], "minus_di": []}
atr_full = atr(candle_dicts_full, period=14) or [] 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 # Pre-build MTF candles ONCE per MTF config
mtf_precomputed = [] mtf_precomputed = []
@@ -294,6 +337,7 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
"price_pivot_lows_full": price_pivot_lows_full, "price_pivot_lows_full": price_pivot_lows_full,
"adx_full": adx_full, "adx_full": adx_full,
"atr_full": atr_full, "atr_full": atr_full,
"atr_pct_full": atr_pct_full,
"mtf_precomputed": mtf_precomputed, "mtf_precomputed": mtf_precomputed,
} }
@@ -332,6 +376,7 @@ def _compute_scores_series(
price_pivot_lows_full = precomputed["price_pivot_lows_full"] price_pivot_lows_full = precomputed["price_pivot_lows_full"]
adx_full = precomputed["adx_full"] adx_full = precomputed["adx_full"]
atr_full = precomputed["atr_full"] atr_full = precomputed["atr_full"]
atr_pct_full = precomputed["atr_pct_full"]
mtf_precomputed = precomputed["mtf_precomputed"] mtf_precomputed = precomputed["mtf_precomputed"]
macd_hist_full = macd_full.get("histogram") if macd_full else None 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 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 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) regime_start = max(0, i + 1 - _REGIME_WINDOW)
atr_history_start = max(0, i + 1 - _ATR_HISTORY_WINDOW)
market_regime = detect_market_regime( market_regime = detect_market_regime(
adx_data, bb_data, atr_pct, vb_data, adx_data, bb_data, atr_pct, vb_data,
prices=close_prices_full[regime_start:i + 1], prices=close_prices_full[regime_start:i + 1],
highs=[c["high"] for c in candle_dicts_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]], 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({ results.append({
@@ -492,6 +539,7 @@ def _compute_scores_series(
"adjusted_score": adjusted_score, "adjusted_score": adjusted_score,
"confidence": confidence, "confidence": confidence,
"market_regime": market_regime, "market_regime": market_regime,
"atr": last_atr,
}) })
return results return results
@@ -518,6 +566,14 @@ def _simulate_from_scores(
adverse slippage a live market order would, instead of pricing fills at adverse slippage a live market order would, instead of pricing fills at
the exact candle close for free — see DEFAULT_TAKER_FEE_PCT/ the exact candle close for free — see DEFAULT_TAKER_FEE_PCT/
DEFAULT_SLIPPAGE_PCT above. 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] = [] all_signals: list[dict] = []
trades: list[dict] = [] trades: list[dict] = []
@@ -569,8 +625,25 @@ def _simulate_from_scores(
) )
if current_position and current_position["status"] == "OPEN": 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"] 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) _close_position(current_position, latest_close, timestamp, "TIME_LIMIT", fee_pct, slippage_pct)
trades.append(current_position) trades.append(current_position)
current_position = None current_position = None
+11 -4
View File
@@ -500,13 +500,19 @@ async def get_indicators(
# Add ADX (Average Directional Index) + Market Regime # Add ADX (Average Directional Index) + Market Regime
computed["adx_data"] = adx(candle_dicts, period=14) computed["adx_data"] = adx(candle_dicts, period=14)
atr_vals = computed.get("supertrend", {}).get("trend", None) 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: try:
from app.services.indicator_service import atr as _calc_atr from app.services.indicator_service import atr as _calc_atr
raw_atr = _calc_atr(candle_dicts, period=14) raw_atr = _calc_atr(candle_dicts, period=14)
last_atr = raw_atr[-1] if raw_atr and len(raw_atr) > 0 else None atr_pct_history = [
last_close = close_prices[-1] if close_prices else 1 (a / c * 100.0) if a and c and c > 0 else None
atr_pct_val = (last_atr / last_close * 100.0) if last_atr and last_close > 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: except Exception:
atr_pct_val = None atr_pct_val = None
@@ -522,6 +528,7 @@ async def get_indicators(
prices=close_prices, prices=close_prices,
highs=high_prices, highs=high_prices,
lows=low_prices, lows=low_prices,
atr_pct_history=atr_pct_history,
) )
computed["market_regime"] = regime computed["market_regime"] = regime
+41 -1
View File
@@ -1114,6 +1114,40 @@ def adx(candles: list[dict], period: int = 14) -> dict[str, list[Optional[float]
# Market Regime Detection # 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( def detect_market_regime(
adx_data: dict[str, list[Optional[float]]], adx_data: dict[str, list[Optional[float]]],
bb: dict[str, list[Optional[float]]], bb: dict[str, list[Optional[float]]],
@@ -1123,6 +1157,7 @@ def detect_market_regime(
highs: list[float] | None = None, highs: list[float] | None = None,
lows: list[float] | None = None, lows: list[float] | None = None,
lookback: int = 20, lookback: int = 20,
atr_pct_history: list[Optional[float]] | None = None,
) -> str: ) -> str:
"""Classify the current market regime using multi-factor analysis. """Classify the current market regime using multi-factor analysis.
@@ -1134,6 +1169,10 @@ def detect_market_regime(
- "breakout" : BB squeeze + volume spike - "breakout" : BB squeeze + volume spike
- "squeeze" : BB very narrow, low volatility before breakout - "squeeze" : BB very narrow, low volatility before breakout
- "choppy" : high CHOP, low ER — completely avoid - "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_vals = adx_data.get("adx", [])
adx_last = adx_vals[-1] if adx_vals and len(adx_vals) >= 1 else None 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: if vol_last is True:
vol_spike = 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 ── # ── Enhanced classification ──
# Priority: squeeze → breakout → choppy → volatile → trending → sideways → neutral # Priority: squeeze → breakout → choppy → volatile → trending → sideways → neutral
+61 -22
View File
@@ -37,6 +37,56 @@ SQUEEZE_ALERT = "SQUEEZE_ALERT"
# Minimum distance from BB bounds to filter noise # Minimum distance from BB bounds to filter noise
MIN_BB_DISTANCE_PCT = Decimal("0.001") # 0.1% 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: def _get_bb_values(indicators: dict) -> dict[str, list[float]] | None:
"""Extract Bollinger Band values from indicators dict.""" """Extract Bollinger Band values from indicators dict."""
@@ -449,31 +499,20 @@ def _compute_adjusted_score(
for s in disabled: for s in disabled:
raw_scores[s] = 0.0 raw_scores[s] = 0.0
# ── P1-16: Correlation dampening ── # ── P1-16 (jj): Pairwise-weighted correlation dampening ──
# Strategies in the same group are highly correlated; dampen when # See CORRELATION_GROUPS/_PAIR_CORRELATION_WEIGHTS/_pair_correlation
# multiple group members agree (same sign) to avoid overconfidence. # near the top of this module for the rationale.
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
]
for group in CORRELATION_GROUPS: for group in CORRELATION_GROUPS:
active = [(s, raw_scores[s]) for s in group if raw_scores[s] != 0.0] 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: for strat, val in active:
if val > 0: same_sign_partners = [
raw_scores[strat] = val * dampen other_strat for other_strat, other_val in active
if neg_count >= 2: if other_strat != strat and (other_val > 0) == (val > 0)
dampen = 1.0 / (neg_count ** 0.5) ]
for strat, val in active: if not same_sign_partners:
if val < 0: 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 raw_scores[strat] = val * dampen
# ── Apply win-rate boosting ── # ── Apply win-rate boosting ──
+29
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from decimal import Decimal from decimal import Decimal
@@ -184,6 +185,21 @@ async def execute_signal_trade(
except Exception: except Exception:
pass 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 ── # ── Hybrid eviction ──
all_open_result = await db.execute( all_open_result = await db.execute(
select(HypotheticalTrade) select(HypotheticalTrade)
@@ -282,6 +298,19 @@ async def execute_signal_trade(
avg_loss=pnl_stats.get("avg_loss", 2.0), avg_loss=pnl_stats.get("avg_loss", 2.0),
confidence=signal_confidence, 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: if kelly_pct > 0:
trade_size = max(trade_size * Decimal(str(kelly_pct)), Decimal("1")) trade_size = max(trade_size * Decimal(str(kelly_pct)), Decimal("1"))
except Exception: except Exception:
+123 -12
View File
@@ -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): async def test_simulate_trades_exits_on_max_hold_candles(monkeypatch, db_session):
_, symbol = await _seed_symbol(db_session) _, symbol = await _seed_symbol(db_session)
base = datetime.now(timezone.utc) - timedelta(hours=40) 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) 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) candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base)
precomputed = backtest_engine._precompute_indicators(candles, "1h") 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) monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake)
_, trades = backtest_engine._simulate_trades( _, 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 assert len(trades) == 1
trade = trades[0] trade = trades[0]
assert trade["status"] == "CLOSED" assert trade["status"] == "CLOSED"
assert trade["exit_reason"] == "TIME_LIMIT" 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() 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) candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base)
precomputed = backtest_engine._precompute_indicators(candles, "1h") precomputed = backtest_engine._precompute_indicators(candles, "1h")
# STRONG_BUY at candle 30 (price 30), STRONG_SELL at candle 35 # STRONG_BUY at candle 30 (price 30), STRONG_SELL at candle 31
# (price 35) reverses and closes it — a straightforward winning # (price 31) reverses and closes it — a small, straightforward
# LONG before costs. The STRONG_SELL that closes it also opens a # 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 # 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. # 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) monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake)
_, trades = backtest_engine._simulate_trades(candles, precomputed, Decimal("10")) _, trades = backtest_engine._simulate_trades(candles, precomputed, Decimal("10"))
assert len(trades) == 2
trade = trades[0] trade = trades[0]
assert trade["direction"] == "LONG" assert trade["direction"] == "LONG"
assert trade["exit_reason"] == "REVERSAL" assert trade["exit_reason"] == "REVERSAL"
@@ -243,7 +253,7 @@ class TestFeeAndSlippage:
slip = backtest_engine.DEFAULT_SLIPPAGE_PCT slip = backtest_engine.DEFAULT_SLIPPAGE_PCT
expected_entry = 30.0 * (1 + slip) expected_entry = 30.0 * (1 + slip)
expected_qty = 10.0 / expected_entry 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_gross = (expected_exit - expected_entry) * expected_qty
expected_fees = (expected_entry + expected_exit) * expected_qty * fee_pct expected_fees = (expected_entry + expected_exit) * expected_qty * fee_pct
expected_net = expected_gross - expected_fees 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) candles = await backtest_engine._fetch_candles(db_session, symbol.id, "1h", since=base)
precomputed = backtest_engine._precompute_indicators(candles, "1h") 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) monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake)
_, trades = backtest_engine._simulate_trades( _, trades = backtest_engine._simulate_trades(
@@ -277,9 +287,9 @@ class TestFeeAndSlippage:
trade = trades[0] trade = trades[0]
assert trade["direction"] == "LONG" assert trade["direction"] == "LONG"
assert trade["entry_price"] == pytest.approx(30.0) 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 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) assert trade["fees"] == pytest.approx(0.0)
async def test_compute_stats_reports_total_fees(self, monkeypatch, db_session): 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(): def test_scores_are_causal_future_prices_dont_change_earlier_scores():
"""Regression test for the look-ahead fix: market_structure() (SMC) and """Regression test for the look-ahead fix: market_structure() (SMC) and
detect_divergence() used to be precomputed ONCE over the whole candle detect_divergence() used to be precomputed ONCE over the whole candle
+74
View File
@@ -23,8 +23,12 @@ from app.services.indicator_service import (
rsi, rsi,
sma, sma,
vwap, vwap,
_adaptive_volatile_threshold,
_find_pivot_highs, _find_pivot_highs,
_find_pivot_lows, _find_pivot_lows,
_DEFAULT_VOLATILE_THRESHOLD_PCT,
_MIN_VOLATILE_THRESHOLD_PCT,
_MAX_VOLATILE_THRESHOLD_PCT,
) )
@@ -269,6 +273,76 @@ class TestDetectMarketRegime:
assert regime == "neutral" 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: class TestPivotCausalConsistency:
"""A pivot at index i is only knowable once `right` bars after it exist """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 (see `_find_pivot_highs`/`_find_pivot_lows`'s definition). Live trading
+62
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import math import math
from decimal import Decimal from decimal import Decimal
import pytest
from app.services.signal_scoring import ( from app.services.signal_scoring import (
BUY, BUY,
CAUTION_LONG, CAUTION_LONG,
@@ -20,10 +22,12 @@ from app.services.signal_scoring import (
_calculate_pnl, _calculate_pnl,
_classify_signal_bb, _classify_signal_bb,
_classify_signal_combined, _classify_signal_combined,
_compute_adjusted_score,
_detect_squeeze, _detect_squeeze,
_get_bb_values, _get_bb_values,
_get_rsi_values, _get_rsi_values,
_get_sma_values, _get_sma_values,
_pair_correlation,
) )
@@ -227,6 +231,64 @@ class TestClassifySignalCombined:
assert (signal_type, strength, confidence) == (CAUTION_SHORT, "MODERATE", 0.5) 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: class TestApplyRegimeFilter:
def test_non_directional_signal_passes_through_for_any_regime(self): def test_non_directional_signal_passes_through_for_any_regime(self):
for regime in ("choppy", "sideways", "volatile", "trending", "neutral", None): for regime in ("choppy", "sideways", "volatile", "trending", "neutral", None):
+149
View File
@@ -156,6 +156,55 @@ async def test_dedup_skips_when_same_direction_already_open(db_session):
assert trades[0].status == "OPEN" 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): async def test_strong_reversal_closes_opposite_trade_and_opens_new_one(db_session):
user = make_user() user = make_user()
db_session.add(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) 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", def make_real_trade(user_id, symbol="BTC/USDT", side="buy", amount="1", price="100",
filled_amount=None, status="filled", created_at=None) -> RealTrade: filled_amount=None, status="filled", created_at=None) -> RealTrade:
return RealTrade( return RealTrade(
+121
View File
@@ -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. |