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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user