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,
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
+11 -4
View File
@@ -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
+41 -1
View File
@@ -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
+63 -24
View File
@@ -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] = {}
+29
View File
@@ -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:
+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):
_, 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
+74
View File
@@ -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
+62
View File
@@ -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):
+149
View File
@@ -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(