fix: symbol-specific Kelly win rate, wire dead sizing code, safer walk-forward fallback

- signal_booster.py: _compute_rates() now also computes a per-symbol win
  rate (not just system-wide/direction aggregates); trade_executor.py's
  Kelly sizing prefers it when the symbol has enough closed-trade history.
  Added _as_datetime() to normalize closed_at across backends/drivers
  that return either a real datetime or a string from raw SQL.
- trade_executor.py: volatility-filter and Kelly-sizing exception handlers
  now log at warning level with the actual exception instead of silently
  swallowing failures that affect how much money a trade risks.
- risk_manager.py: compute_partial_tp_levels() now returns all 3 levels
  its docstring always promised (TP1 25% + TP2 35% + 40% trailing
  remainder) instead of silently dropping the last 40%.
- trade_executor.py: compute_volatility_adjusted_size() was dead code;
  now applied as a multiplier on the Kelly-derived trade_size (using
  max_risk_pct=100 to reinterpret it as "scale the already-sized trade"
  rather than "% of a bankroll", which would always collapse to this
  pipeline's $5 floor at its actual dollar scale).
- walk_forward.py: grid-search fallback (when every combo is too sparse
  to trust) now picks the combo with the most trades/highest PnL instead
  of always the grid's arbitrary first entry. Raised MIN_TRADES_PER_FOLD
  5 -> 15 for a more defensible statistical minimum.

219 backend tests pass (+10).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 17:22:33 +07:00
parent cccef51eaf
commit 2399d0cb0c
9 changed files with 552 additions and 31 deletions
+19 -5
View File
@@ -134,21 +134,35 @@ class TestAdaptiveSLTPOptimizerComputeSlTp:
class TestAdaptiveSLTPOptimizerPartialTpLevels:
"""Regression tests for fix (nn): the docstring always promised TP1
25% + TP2 35% + a 40% trailing remainder (100% of the position
accounted for), but the code only ever returned the first two levels
(60% total) — silently leaving the other 40% unaccounted for from a
caller's point of view.
"""
def setup_method(self):
self.opt = AdaptiveSLTPOptimizer()
def test_returns_two_levels_summing_close_percentage_below_one(self):
def test_returns_three_levels_summing_close_percentage_to_one(self):
levels = self.opt.compute_partial_tp_levels(atr=100.0, entry_price=50_000.0, regime="trending", direction="LONG")
assert len(levels) == 2
assert len(levels) == 3
total_close_pct = sum(lvl["close_percentage"] for lvl in levels)
assert 0 < total_close_pct <= 1.0
assert total_close_pct == pytest.approx(1.0)
def test_first_two_levels_have_fixed_prices_third_is_trailing_remainder(self):
levels = self.opt.compute_partial_tp_levels(atr=100.0, entry_price=50_000.0, regime="trending", direction="LONG")
assert levels[0]["price"] is not None and levels[0]["trailing"] is False
assert levels[1]["price"] is not None and levels[1]["trailing"] is False
assert levels[2]["price"] is None and levels[2]["trailing"] is True
assert levels[2]["close_percentage"] == pytest.approx(0.40)
def test_long_levels_are_above_entry_and_increasing(self):
levels = self.opt.compute_partial_tp_levels(atr=100.0, entry_price=50_000.0, regime="trending", direction="LONG")
assert levels[0]["price"] < levels[1]["price"]
assert all(lvl["price"] > 50_000.0 for lvl in levels)
assert all(lvl["price"] > 50_000.0 for lvl in levels[:2])
def test_short_levels_are_below_entry_and_decreasing(self):
levels = self.opt.compute_partial_tp_levels(atr=100.0, entry_price=50_000.0, regime="trending", direction="SHORT")
assert levels[0]["price"] > levels[1]["price"]
assert all(lvl["price"] < 50_000.0 for lvl in levels)
assert all(lvl["price"] < 50_000.0 for lvl in levels[:2])
+79
View File
@@ -0,0 +1,79 @@
"""Tests for app/services/signal_booster.py's `_compute_rates` — the raw-SQL
win-rate aggregation that Kelly sizing reads from. Runs against a real
(in-memory SQLite) DB so the actual query logic is exercised, not mocked.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from app.models.signal import HypotheticalTrade
from app.services.signal_booster import _compute_rates
def make_trade(symbol, direction, pnl, entry_reason="double_bb_rsi", closed_at=None):
now = datetime.now(timezone.utc)
return HypotheticalTrade(
symbol=symbol, exchange="mexc", timeframe="1h", direction=direction,
entry_price=Decimal("100"), entry_time=now - timedelta(hours=1),
entry_reason=entry_reason, exit_price=Decimal("110" if pnl > 0 else "90"),
exit_time=now, quantity=Decimal("1"), pnl=Decimal(str(pnl)),
pnl_percent=Decimal("10"), status="CLOSED",
closed_at=closed_at or now,
)
class TestSymbolSpecificRates:
"""Regression tests for fix (mm): Kelly sizing used to only ever see a
system-wide `__all__` (and direction-level `__all___{LONG,SHORT}`) win
rate — never a specific symbol's own performance, even when that
symbol has plenty of its own closed-trade history that trades very
differently from the aggregate.
"""
async def test_symbol_with_enough_trades_gets_its_own_rate(self, db_session):
# BTC/USDT: 8 wins, 0 losses (needs >= MIN_TRADES*0.5 = 7.5 weighted trades).
for _ in range(8):
db_session.add(make_trade("BTC/USDT", "LONG", pnl=10.0))
await db_session.flush()
rates = await _compute_rates(db_session)
assert rates["__symbol__BTC/USDT"] == 1.0
async def test_symbol_with_too_few_trades_has_no_own_rate(self, db_session):
# Only 2 trades — well under the MIN_TRADES*0.5 threshold.
db_session.add(make_trade("ETH/USDT", "LONG", pnl=10.0))
db_session.add(make_trade("ETH/USDT", "LONG", pnl=-10.0))
await db_session.flush()
rates = await _compute_rates(db_session)
assert "__symbol__ETH/USDT" not in rates
async def test_different_symbols_do_not_share_a_rate(self, db_session):
for _ in range(8):
db_session.add(make_trade("WINNER/USDT", "LONG", pnl=10.0))
for _ in range(8):
db_session.add(make_trade("LOSER/USDT", "LONG", pnl=-10.0))
await db_session.flush()
rates = await _compute_rates(db_session)
assert rates["__symbol__WINNER/USDT"] == 1.0
assert rates["__symbol__LOSER/USDT"] == 0.0
async def test_direction_and_overall_rates_are_still_computed(self, db_session):
"""The symbol-rate addition must not break the existing __all__ /
__all___{direction} aggregates it was computed alongside."""
for _ in range(8):
db_session.add(make_trade("BTC/USDT", "LONG", pnl=10.0))
for _ in range(8):
db_session.add(make_trade("BTC/USDT", "SHORT", pnl=-10.0))
await db_session.flush()
rates = await _compute_rates(db_session)
assert rates["__all___LONG"] == 1.0
assert rates["__all___SHORT"] == 0.0
assert rates["__all__"] == 0.5
+136
View File
@@ -450,6 +450,142 @@ class TestKellyPortfolioCorrelationDampening:
assert float(hedged_trade.quantity) == pytest.approx(float(baseline_trade.quantity), rel=1e-6)
class TestKellySymbolSpecificWinRate:
"""Regression test for fix (mm): Kelly sizing used to always size off
the system-wide `__all___{direction}` win rate, even when the specific
symbol being traded has its own (very different) win-rate history with
plenty of samples — now it prefers the symbol's own rate when present.
"""
async def test_symbol_specific_rate_overrides_direction_aggregate(self, db_session, monkeypatch):
import app.services.signal_booster as signal_booster_module
async def fake_rates():
return {
"__all__": 0.5, "__all___LONG": 0.5,
# BTC/USDT trades far better than the system-wide average.
"__symbol__BTC/USDT": 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)
# Restrict each user to only their own symbol — otherwise both
# users (empty auto_trade_tokens = trades everything) would get
# BOTH the ETH and BTC trade from each call below, and the second
# user's open-position count from the first call would trigger
# portfolio-correlation dampening (fix hh) that has nothing to do
# with what this test is isolating.
weak_direction_user = make_user(trade_size=200, auto_trade_tokens=["ETH/USDT"])
strong_symbol_user = make_user(trade_size=200, auto_trade_tokens=["BTC/USDT"])
db_session.add_all([weak_direction_user, strong_symbol_user])
await db_session.flush()
confident_signal_eth = Signal(
symbol="ETH/USDT", 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}),
)
confident_signal_btc = Signal(
symbol="BTC/USDT", 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}),
)
db_session.add_all([confident_signal_eth, confident_signal_btc])
await db_session.flush()
# ETH/USDT has no rate of its own -> falls back to __all___LONG (0.5).
await execute_signal_trade(db_session, confident_signal_eth, "ETH/USDT", "mexc", "1h", Decimal("50000"))
# BTC/USDT has its own, much higher, rate (0.9) -> should size larger.
await execute_signal_trade(db_session, confident_signal_btc, "BTC/USDT", "mexc", "1h", Decimal("50000"))
eth_trade = (await _open_trades_for(db_session, weak_direction_user.id, "ETH/USDT"))[0]
btc_trade = (await _open_trades_for(db_session, strong_symbol_user.id, "BTC/USDT"))[0]
assert float(btc_trade.quantity) > float(eth_trade.quantity)
class TestVolatilityRegimeSizeAdjustment:
"""Regression test for fix (oo): `compute_volatility_adjusted_size`
was dead code — the volatility filter only ever skipped a trade
outright above 8%/below 0.5% ATR, with no effect at all in between.
It's now applied as a multiplier on the Kelly-derived trade_size using
the same ATR%/regime already read off the signal snapshot.
"""
def _signal_with_atr_and_regime(self, symbol, atr_abs, regime):
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({
"atr_14": [atr_abs], "confidence": 1.0, "market_regime": regime,
}),
)
def _patch_kelly_inputs(self, monkeypatch):
import app.services.signal_booster as signal_booster_module
async def fake_rates():
return {"__all__": 0.6, "__all___LONG": 0.6}
async def fake_pnl_stats():
return {"avg_win": 3.0, "avg_loss": 2.0}
monkeypatch.setattr(signal_booster_module, "get_cached_rates", fake_rates)
monkeypatch.setattr(signal_booster_module, "get_pnl_stats", fake_pnl_stats)
async def test_volatile_regime_and_high_atr_shrinks_size_vs_calm_neutral(self, db_session, monkeypatch):
self._patch_kelly_inputs(monkeypatch)
calm_user = make_user(trade_size=200, auto_trade_tokens=["CALM/USDT"])
volatile_user = make_user(trade_size=200, auto_trade_tokens=["WILD/USDT"])
db_session.add_all([calm_user, volatile_user])
await db_session.flush()
# current_price=100 -> atr_pct = atr_abs (since atr_abs/100*100 = atr_abs).
calm_signal = self._signal_with_atr_and_regime("CALM/USDT", atr_abs=2.0, regime="neutral")
volatile_signal = self._signal_with_atr_and_regime("WILD/USDT", atr_abs=6.0, regime="volatile")
db_session.add_all([calm_signal, volatile_signal])
await db_session.flush()
await execute_signal_trade(db_session, calm_signal, "CALM/USDT", "mexc", "1h", Decimal("100"))
await execute_signal_trade(db_session, volatile_signal, "WILD/USDT", "mexc", "1h", Decimal("100"))
calm_trade = (await _open_trades_for(db_session, calm_user.id, "CALM/USDT"))[0]
volatile_trade = (await _open_trades_for(db_session, volatile_user.id, "WILD/USDT"))[0]
assert float(volatile_trade.quantity) < float(calm_trade.quantity)
async def test_no_atr_data_leaves_sizing_unaffected(self, db_session, monkeypatch):
"""No atr_14 in the snapshot (e.g. an older/partial signal) must
skip this adjustment entirely rather than erroring or applying a
default that changes existing sizing behavior."""
self._patch_kelly_inputs(monkeypatch)
user = make_user(trade_size=200)
db_session.add(user)
await db_session.flush()
signal = Signal(
symbol="BTC/USDT", 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}), # no atr_14
)
db_session.add(signal)
await db_session.flush()
await execute_signal_trade(db_session, signal, "BTC/USDT", "mexc", "1h", Decimal("100"))
trade = (await _open_trades_for(db_session, user.id, "BTC/USDT"))[0]
assert trade.quantity > 0
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(
+56 -7
View File
@@ -51,10 +51,11 @@ def test_fold_score_rejects_too_few_trades():
def test_fold_score_prefers_consistent_edge_over_lucky_streak():
# Same total PnL (50), but one is steady small wins, the other is one
# Same total PnL (150), but one is steady small wins, the other is one
# huge win plus several losses — the steadier one should score higher.
consistent = [{"pnl": 10.0} for _ in range(5)]
lucky = [{"pnl": 50.0}, {"pnl": -10.0}, {"pnl": -10.0}, {"pnl": -10.0}, {"pnl": -10.0}]
# Both need >= MIN_TRADES_PER_FOLD trades to get a real (non -inf) score.
consistent = [{"pnl": 10.0} for _ in range(15)]
lucky = [{"pnl": 290.0}] + [{"pnl": -10.0} for _ in range(14)]
score_consistent = walk_forward._fold_score(consistent)
score_lucky = walk_forward._fold_score(lucky)
@@ -63,9 +64,9 @@ def test_fold_score_prefers_consistent_edge_over_lucky_streak():
def test_fold_score_zero_variance_all_same_sign():
trades = [{"pnl": 10.0} for _ in range(6)]
trades = [{"pnl": 10.0} for _ in range(16)]
score = walk_forward._fold_score(trades)
assert score == pytest.approx(10.0 * math.sqrt(6))
assert score == pytest.approx(10.0 * math.sqrt(16))
# ── _max_drawdown_pct ──────────────────────────────────────────────────
@@ -91,7 +92,7 @@ async def test_grid_search_picks_the_best_scoring_combo(monkeypatch):
def fake_run_combo(candles, scores_series, trade_size, params, fee_pct=None, slippage_pct=None):
if params == good_params:
trades = [{"pnl": 10.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 110.0} for _ in range(10)]
trades = [{"pnl": 10.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 110.0} for _ in range(15)]
else:
trades = [
{"pnl": 1.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0},
@@ -107,7 +108,7 @@ async def test_grid_search_picks_the_best_scoring_combo(monkeypatch):
)
assert best_params == good_params
assert best_stats["trades"]["total"] == 10
assert best_stats["trades"]["total"] == 15
assert best_score > float("-inf")
@@ -130,6 +131,54 @@ async def test_grid_search_falls_back_when_every_combo_too_sparse(monkeypatch):
assert best_stats["trades"]["total"] == 1
@pytest.mark.asyncio
async def test_grid_search_fallback_prefers_more_trades_not_first_combo(monkeypatch):
"""Regression test for fix (xx): when every combo is too sparse to
trust via _fold_score, the fallback used to always pick the grid's
first entry regardless of how sparse/lucky it was. It should now pick
whichever sparse combo has the most trades (closer to statistically
meaningful) — here, the SECOND combo (4.5) — not the first (3.5)."""
def fake_run_combo(candles, scores_series, trade_size, params, fee_pct=None, slippage_pct=None):
if params["strong_threshold"] == 3.5:
trades = [{"pnl": 1.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0}]
else:
trades = [
{"pnl": 1.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0},
{"pnl": 1.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0},
{"pnl": 1.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0},
]
return [], trades
monkeypatch.setattr(walk_forward, "_run_combo", fake_run_combo)
grid = {"strong_threshold": [3.5, 4.5], "signal_threshold": [1.0], "max_hold_candles": [48]}
best_params, best_score, best_stats = walk_forward._grid_search(
candles=[], scores_series=[], param_grid=grid, trade_size=Decimal("10"),
)
assert best_params == {"strong_threshold": 4.5, "signal_threshold": 1.0, "max_hold_candles": 48}
assert best_score == float("-inf")
assert best_stats["trades"]["total"] == 3
@pytest.mark.asyncio
async def test_grid_search_fallback_breaks_ties_with_higher_pnl(monkeypatch):
"""Same trade count for both sparse combos -> tie-break on total PnL."""
def fake_run_combo(candles, scores_series, trade_size, params, fee_pct=None, slippage_pct=None):
pnl = 1.0 if params["strong_threshold"] == 3.5 else 5.0
return [], [{"pnl": pnl, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0}]
monkeypatch.setattr(walk_forward, "_run_combo", fake_run_combo)
grid = {"strong_threshold": [3.5, 4.5], "signal_threshold": [1.0], "max_hold_candles": [48]}
best_params, _best_score, best_stats = walk_forward._grid_search(
candles=[], scores_series=[], param_grid=grid, trade_size=Decimal("10"),
)
assert best_params == {"strong_threshold": 4.5, "signal_threshold": 1.0, "max_hold_candles": 48}
assert best_stats["trades"]["total_pnl"] == pytest.approx(5.0)
# ── end-to-end (small synthetic dataset) ─────────────────────────────────
async def _seed_symbol(db_session, name="BTC/USDT", exchange_name="mexc"):