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:
@@ -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