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
+14
View File
@@ -179,6 +179,12 @@ class AdaptiveSLTPOptimizer:
- TP1: ATR × 2.0 → close 25% - TP1: ATR × 2.0 → close 25%
- TP2: ATR × 4.0 → close 35% - TP2: ATR × 4.0 → close 35%
- Remainder: 40% with trailing stop - Remainder: 40% with trailing stop
The three `close_percentage`s always sum to 1.0 (100% of the
position) — the first two are fixed-price take-profit levels, the
third has `price: None` and `trailing: True` since "trail the
remainder" is a runtime stop-management decision, not a price
this function can compute in isolation.
""" """
params = REGIME_MULTIPLIERS.get(regime, REGIME_MULTIPLIERS["neutral"]) params = REGIME_MULTIPLIERS.get(regime, REGIME_MULTIPLIERS["neutral"])
direction = direction.upper() direction = direction.upper()
@@ -198,6 +204,14 @@ class AdaptiveSLTPOptimizer:
result.append({ result.append({
"price": round(price, 8), "price": round(price, 8),
"close_percentage": level["close_pct"], "close_percentage": level["close_pct"],
"trailing": False,
}) })
remainder_pct = 1.0 - sum(level["close_pct"] for level in levels)
result.append({
"price": None,
"close_percentage": round(remainder_pct, 8),
"trailing": True,
})
return result return result
+49 -6
View File
@@ -50,6 +50,31 @@ _STRATEGY_MAP: dict[str, str] = {
} }
def _as_datetime(value: Any) -> datetime | None:
"""Normalize a raw `closed_at` value from `text()` SQL to a datetime.
Raw textual SQL (unlike ORM queries) carries no column type
information, so the driver returns whatever native type it stores
timestamps as — Postgres/asyncpg gives back a real `datetime`, but
SQLite/aiosqlite (used in this project's test suite) gives back a
plain string. Without this, the exponential-decay weighting below
would raise on any backend/driver that doesn't hand back a `datetime`.
"""
if value is None:
return None
if isinstance(value, str):
try:
value = datetime.fromisoformat(value)
except ValueError:
return None
if not isinstance(value, datetime):
return None
# This app stores/consumes timestamps as UTC throughout — a
# driver/backend that hands back a naive datetime (e.g. SQLite) means
# "UTC with the tzinfo stripped", not "some unspecified local time".
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
# ── Core helpers ────────────────────────────────────────────────────────── # ── Core helpers ──────────────────────────────────────────────────────────
async def compute_strategy_win_rates(db: AsyncSession | None = None) -> dict[str, float]: async def compute_strategy_win_rates(db: AsyncSession | None = None) -> dict[str, float]:
@@ -109,8 +134,8 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
for row in rows: for row in rows:
strategy = str(row[0]) strategy = str(row[0])
is_win = int(row[1]) is_win = int(row[1])
closed_at = row[2] closed_at = _as_datetime(row[2])
if closed_at: if closed_at:
days_ago = (now_dt - closed_at).days days_ago = (now_dt - closed_at).days
weight = math.exp(-DECAY_LAMBDA * max(days_ago, 0)) weight = math.exp(-DECAY_LAMBDA * max(days_ago, 0))
@@ -120,10 +145,13 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
strategy_weights[strategy] = strategy_weights.get(strategy, 0) + weight strategy_weights[strategy] = strategy_weights.get(strategy, 0) + weight
strategy_wins[strategy] = strategy_wins.get(strategy, 0) + (weight if is_win else 0) strategy_wins[strategy] = strategy_wins.get(strategy, 0) + (weight if is_win else 0)
# Also compute direction-specific rates from the same data # Also compute direction- and symbol-specific rates from the same
# data in one query (fix mm: Kelly sizing prefers a symbol's own
# win rate over the system-wide aggregate when there's enough
# history for that specific symbol).
dir_result = await db.execute( dir_result = await db.execute(
text(""" text("""
SELECT direction, SELECT direction, symbol,
CASE WHEN pnl > 0 THEN 1 ELSE 0 END, CASE WHEN pnl > 0 THEN 1 ELSE 0 END,
closed_at closed_at
FROM hypothetical_trades FROM hypothetical_trades
@@ -133,10 +161,13 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
LIMIT 5000 LIMIT 5000
"""), """),
) )
symbol_weights: dict[str, float] = {}
symbol_wins: dict[str, float] = {}
for row in dir_result.all(): for row in dir_result.all():
direction = str(row[0]) if row[0] else "UNKNOWN" direction = str(row[0]) if row[0] else "UNKNOWN"
is_win = int(row[1]) symbol = str(row[1]) if row[1] else None
closed_at = row[2] is_win = int(row[2])
closed_at = _as_datetime(row[3])
if closed_at: if closed_at:
days_ago = (now_dt - closed_at).days days_ago = (now_dt - closed_at).days
weight = math.exp(-DECAY_LAMBDA * max(days_ago, 0)) weight = math.exp(-DECAY_LAMBDA * max(days_ago, 0))
@@ -144,6 +175,9 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
weight = 0.5 weight = 0.5
direction_weights[direction] = direction_weights.get(direction, 0) + weight direction_weights[direction] = direction_weights.get(direction, 0) + weight
direction_wins[direction] = direction_wins.get(direction, 0) + (weight if is_win else 0) direction_wins[direction] = direction_wins.get(direction, 0) + (weight if is_win else 0)
if symbol:
symbol_weights[symbol] = symbol_weights.get(symbol, 0) + weight
symbol_wins[symbol] = symbol_wins.get(symbol, 0) + (weight if is_win else 0)
rates: dict[str, float] = {} rates: dict[str, float] = {}
total_weight = 0.0 total_weight = 0.0
@@ -172,6 +206,15 @@ async def _compute_rates(db: AsyncSession) -> dict[str, float]:
if dw >= MIN_TRADES * 0.5: if dw >= MIN_TRADES * 0.5:
rates[f"__all___{direction}"] = ww / dw rates[f"__all___{direction}"] = ww / dw
# Symbol-specific rates (fix mm) — same MIN_TRADES bar as everything
# else here, so a thinly-traded symbol falls back to the direction/
# overall aggregate instead of a noisy few-trade estimate.
for symbol in symbol_weights:
sw = symbol_weights.get(symbol, 0)
sww = symbol_wins.get(symbol, 0)
if sw >= MIN_TRADES * 0.5:
rates[f"__symbol__{symbol}"] = sww / sw
_win_rate_cache = rates _win_rate_cache = rates
_last_cache_update = datetime.now(timezone.utc) _last_cache_update = datetime.now(timezone.utc)
await redis_client.set_json(_REDIS_KEY_WIN_RATES, rates, _CACHE_TTL_SECONDS) await redis_client.set_json(_REDIS_KEY_WIN_RATES, rates, _CACHE_TTL_SECONDS)
+53 -5
View File
@@ -171,19 +171,29 @@ async def execute_signal_trade(
continue continue
# ── Volatility filter ── # ── Volatility filter ──
# atr_pct_for_sizing survives past this block (fix oo) so the Kelly
# sizing step below can scale size down gracefully between the two
# skip thresholds, instead of ATR only ever being a binary
# skip/allow gate with no effect in between.
atr_pct_for_sizing: float | None = None
try: try:
snap = json.loads(signal.indicators_snapshot) if signal.indicators_snapshot else {} snap = json.loads(signal.indicators_snapshot) if signal.indicators_snapshot else {}
atr_val = snap.get("atr_14") atr_val = snap.get("atr_14")
if atr_val and isinstance(atr_val, list) and len(atr_val) > 0 and atr_val[-1]: if atr_val and isinstance(atr_val, list) and len(atr_val) > 0 and atr_val[-1]:
atr_pct = float(atr_val[-1]) / float(current_price) * 100 atr_pct = float(atr_val[-1]) / float(current_price) * 100
atr_pct_for_sizing = atr_pct
if atr_pct > 8.0: if atr_pct > 8.0:
logger.info("⛔ Skipping %s — ATR too high: %.2f%%", symbol, atr_pct) logger.info("⛔ Skipping %s — ATR too high: %.2f%%", symbol, atr_pct)
continue continue
if atr_pct < 0.5: if atr_pct < 0.5:
logger.info("⛔ Skipping %s — ATR too low: %.2f%%", symbol, atr_pct) logger.info("⛔ Skipping %s — ATR too low: %.2f%%", symbol, atr_pct)
continue continue
except Exception: except Exception as e:
pass # A parse/format error here must not silently disable the
# volatility gate in production with no trace of why — surface
# it, even though we still proceed (fail-open, matching the
# existing behavior of not blocking a trade over a data glitch).
logger.warning("Volatility filter failed for %s, proceeding without it: %s", symbol, e)
# ── Serialize per-user trade-opening decisions (fix ll) ── # ── Serialize per-user trade-opening decisions (fix ll) ──
# The `with_for_update()` on all_open_trades below only locks rows # The `with_for_update()` on all_open_trades below only locks rows
@@ -288,12 +298,19 @@ async def execute_signal_trade(
kelly = DynamicKellySizer() kelly = DynamicKellySizer()
overall_rate = rates.get("__all__", 0.5) overall_rate = rates.get("__all__", 0.5)
dir_rate = rates.get(f"__all___{signal_direction}", overall_rate) dir_rate = rates.get(f"__all___{signal_direction}", overall_rate)
# (mm) Prefer this specific symbol's own win rate when there's
# enough history for it — a coin that trades very differently
# from the system-wide average (e.g. a consistently weaker
# altcoin) should be sized off its own edge, not the aggregate.
# Falls back to the direction/overall aggregate exactly as
# before when the symbol doesn't have enough closed trades yet.
win_rate = rates.get(f"__symbol__{symbol}", dir_rate)
signal_confidence = 0.5 signal_confidence = 0.5
if signal.indicators_snapshot: if signal.indicators_snapshot:
snap = json.loads(signal.indicators_snapshot) snap = json.loads(signal.indicators_snapshot)
signal_confidence = snap.get("confidence", 0.5) signal_confidence = snap.get("confidence", 0.5)
kelly_pct = kelly.compute_kelly_pct( kelly_pct = kelly.compute_kelly_pct(
win_rate=dir_rate, win_rate=win_rate,
avg_win=pnl_stats.get("avg_win", 3.0), avg_win=pnl_stats.get("avg_win", 3.0),
avg_loss=pnl_stats.get("avg_loss", 2.0), avg_loss=pnl_stats.get("avg_loss", 2.0),
confidence=signal_confidence, confidence=signal_confidence,
@@ -313,8 +330,39 @@ async def execute_signal_trade(
kelly_pct *= 1.0 / math.sqrt(same_direction_open + 1) kelly_pct *= 1.0 / math.sqrt(same_direction_open + 1)
if kelly_pct > 0: if kelly_pct > 0:
trade_size = max(trade_size * Decimal(str(kelly_pct)), Decimal("1")) trade_size = max(trade_size * Decimal(str(kelly_pct)), Decimal("1"))
except Exception:
logger.debug("Kelly sizing failed, using fixed trade_size") # ── Volatility/regime adjustment (fix oo) ──
# `compute_volatility_adjusted_size` was previously never
# called anywhere — the volatility filter above only ever
# skipped a trade outright above/below its two hard cutoffs,
# with no graduated effect in between. `max_risk_pct=100` here
# means "scale 100% of the Kelly-derived trade_size by
# volatility/regime" rather than the function's own docstring
# framing ("% of a bankroll to risk") — that framing assumes a
# much larger base_size (an account balance) than this
# paper-trading pipeline's small fixed trade_size, where a
# literal 1-2% risk-per-trade would always collapse to the $5
# floor below regardless of volatility. Reusing the same
# vol_factor/regime_factor math as a pure multiplier on the
# already-sized trade instead keeps it meaningful at this
# pipeline's actual dollar scale.
if atr_pct_for_sizing is not None:
regime = "neutral"
if signal.indicators_snapshot:
regime = snap.get("market_regime") or "neutral"
trade_size = kelly.compute_volatility_adjusted_size(
base_size=trade_size,
atr_pct=Decimal(str(atr_pct_for_sizing)),
max_risk_pct=Decimal("100"),
regime=regime,
)
except Exception as e:
# Silently falling back here used to hide real bugs in the
# sizing pipeline (wrong rates shape, bad Decimal conversion,
# etc.) — this affects how much real/paper money a trade
# risks, so a failure here should be visible, not just a
# debug-level breadcrumb.
logger.warning("Kelly sizing failed for %s, using fixed trade_size: %s", symbol, e)
# Sane size bounds # Sane size bounds
trade_size = max(trade_size, Decimal("5")) trade_size = max(trade_size, Decimal("5"))
+32 -8
View File
@@ -47,7 +47,15 @@ DEFAULT_PARAM_GRID: dict[str, list[float]] = {
"max_hold_candles": [24, 48, 96], "max_hold_candles": [24, 48, 96],
} }
MIN_TRADES_PER_FOLD = 5 # reject param combos too sparse to trust # (fix rr) Reject param combos too sparse to trust. 5 trades is too thin a
# sample to estimate a Sharpe-like ratio's mean/std reliably — a couple of
# outlier trades can swing it wildly. 15 is still well short of the ~30
# quant practitioners often cite for a stable estimate, but demanding 30
# per fold would starve most folds of any "trustworthy" combo at all given
# this system's selective (STRONG-only) entry signals — 15 is a middle
# ground between statistical caution and having enough folds to walk
# forward over at all.
MIN_TRADES_PER_FOLD = 15
WARMUP_BUFFER_CANDLES = 60 # extra history fetched before each window so indicators aren't cold at window start WARMUP_BUFFER_CANDLES = 60 # extra history fetched before each window so indicators aren't cold at window start
_TF_MINUTES = {"15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440, "1w": 10080, "1M": 43200} _TF_MINUTES = {"15m": 15, "30m": 30, "1h": 60, "4h": 240, "1d": 1440, "1w": 10080, "1M": 43200}
@@ -175,7 +183,20 @@ def _grid_search(
keys = list(param_grid.keys()) keys = list(param_grid.keys())
best_params: dict[str, float] | None = None best_params: dict[str, float] | None = None
best_score = float("-inf") best_score = float("-inf")
best_stats: dict = {} best_signals: list[dict] | None = None
best_trades: list[dict] | None = None
# (fix xx) If every combo is too sparse to trust (_fold_score returns
# -inf for all of them), we still need to report *something* for the
# fold — track the least-bad combo as we go instead of always falling
# back to the grid's arbitrary first entry, which could easily be the
# worst-performing one. Ranked by (trade count, total PnL): more trades
# means closer to being statistically trustworthy in the first place,
# and PnL breaks ties between equally-sparse combos.
fallback_params: dict[str, float] | None = None
fallback_rank: tuple[int, float] = (-1, float("-inf"))
fallback_signals: list[dict] | None = None
fallback_trades: list[dict] | None = None
for combo in product(*(param_grid[k] for k in keys)): for combo in product(*(param_grid[k] for k in keys)):
params = dict(zip(keys, combo)) params = dict(zip(keys, combo))
@@ -185,15 +206,18 @@ def _grid_search(
if score > best_score: if score > best_score:
best_score = score best_score = score
best_params = params best_params = params
best_stats = _compute_stats(all_signals, trades) best_signals, best_trades = all_signals, trades
rank = (len(closed_trades), sum(float(t.get("pnl", 0.0)) for t in closed_trades))
if rank > fallback_rank:
fallback_rank = rank
fallback_params = params
fallback_signals, fallback_trades = all_signals, trades
if best_params is None: if best_params is None:
# Every combo scored -inf (too few trades) — still report the best_params, best_signals, best_trades = fallback_params, fallback_signals, fallback_trades
# grid's first combination so the fold has *something* to show.
best_params = {k: param_grid[k][0] for k in keys}
all_signals, trades = _run_combo(candles, scores_series, trade_size, best_params, fee_pct, slippage_pct)
best_stats = _compute_stats(all_signals, trades)
best_stats = _compute_stats(best_signals, best_trades)
return best_params, best_score, best_stats return best_params, best_score, best_stats
+19 -5
View File
@@ -134,21 +134,35 @@ class TestAdaptiveSLTPOptimizerComputeSlTp:
class TestAdaptiveSLTPOptimizerPartialTpLevels: 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): def setup_method(self):
self.opt = AdaptiveSLTPOptimizer() 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") 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) 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): 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") 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 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): 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") 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 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) 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", def make_real_trade(user_id, symbol="BTC/USDT", side="buy", amount="1", price="100",
filled_amount=None, status="filled", created_at=None) -> RealTrade: filled_amount=None, status="filled", created_at=None) -> RealTrade:
return RealTrade( return RealTrade(
+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(): 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. # huge win plus several losses — the steadier one should score higher.
consistent = [{"pnl": 10.0} for _ in range(5)] # Both need >= MIN_TRADES_PER_FOLD trades to get a real (non -inf) score.
lucky = [{"pnl": 50.0}, {"pnl": -10.0}, {"pnl": -10.0}, {"pnl": -10.0}, {"pnl": -10.0}] 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_consistent = walk_forward._fold_score(consistent)
score_lucky = walk_forward._fold_score(lucky) 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(): 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) 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 ────────────────────────────────────────────────── # ── _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): def fake_run_combo(candles, scores_series, trade_size, params, fee_pct=None, slippage_pct=None):
if params == good_params: 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: else:
trades = [ 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},
@@ -107,7 +108,7 @@ async def test_grid_search_picks_the_best_scoring_combo(monkeypatch):
) )
assert best_params == good_params assert best_params == good_params
assert best_stats["trades"]["total"] == 10 assert best_stats["trades"]["total"] == 15
assert best_score > float("-inf") 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 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) ───────────────────────────────── # ── end-to-end (small synthetic dataset) ─────────────────────────────────
async def _seed_symbol(db_session, name="BTC/USDT", exchange_name="mexc"): async def _seed_symbol(db_session, name="BTC/USDT", exchange_name="mexc"):
+114
View File
@@ -0,0 +1,114 @@
# Theo dõi đánh giá dự án Trading Portal — v12
> **Ngày đánh giá gốc:** 2026-07-03
> **Cập nhật v1-v11:** xem các file `theo_doi_trading-portal_v1.md`…`v11.md`
> **Cập nhật v12 (lần này):** 2026-07-04 — triển khai "Nhóm A" (6 vấn đề ưu tiên trung bình còn tồn đọng, rủi ro thấp, không đụng tham số cần thực nghiệm): (mm) Kelly theo symbol, (ss) log lỗi rõ ràng, (nn) sửa `compute_partial_tp_levels`, (oo) wire `compute_volatility_adjusted_size`, (xx) fallback grid search chọn combo tốt nhất, (rr) nâng `MIN_TRADES_PER_FOLD`
> **Người thực hiện:** Claude (Sonnet 5), theo yêu cầu của tien.a.le@accenture.com
> **Quy ước đặt tên:** Mỗi lần có thay đổi lớn → tạo bản mới `theo_doi_trading-portal_v13.md`, ... giữ nguyên các bản cũ làm lịch sử.
---
## 1. Tổng quan dự án
(Không đổi — xem [theo_doi_trading-portal_v2.md](theo_doi_trading-portal_v2.md) mục 1.)
---
## 2. Bối cảnh
Sau khi hoàn tất 5 vấn đề ưu tiên cao ở v11, người dùng hỏi tiếp về các mục còn lại. Danh sách còn lại được chia 3 nhóm theo bản chất: **Nhóm A** (sửa lỗi/dọn code rõ ràng, rủi ro thấp), **Nhóm B** (điều chỉnh tham số/ngưỡng, cần thực nghiệm để biết đúng/sai), và **Nhóm C = (tt)** (tối ưu lại toàn bộ tham số kinh điển của 13 thuật toán — bản chất là bài toán data-science cần chạy walk-forward thực tế, không phải việc sửa code). Người dùng chọn làm hết Nhóm A.
---
## 3. Các thay đổi trong v12 (Nhóm A)
### 3.1 (mm) Kelly sizing ưu tiên win-rate theo symbol cụ thể
**Vấn đề:** Kelly sizing chỉ có 2 cấp win-rate: `__all__` (gộp toàn hệ thống) và `__all___{direction}` (theo hướng LONG/SHORT gộp toàn hệ thống) — không bao giờ biết một symbol cụ thể có đang trade tốt/tệ hơn mức trung bình hay không, dù có đủ dữ liệu lịch sử riêng cho symbol đó.
**Đã sửa:**
- `backend/app/services/signal_booster.py`: `_compute_rates()` giờ tính thêm win-rate theo từng symbol (`rates[f"__symbol__{symbol}"]`) từ CÙNG một query đã có (gộp thêm cột `symbol`, không tốn thêm round-trip DB), áp dụng đúng ngưỡng `MIN_TRADES` như các rate khác.
- Phát hiện phụ trong lúc viết test: hàm này khi chạy với raw SQL `text()` qua SQLite (test) trả về `closed_at` dạng string thay vì `datetime` (Postgres/asyncpg mới trả `datetime` thật) — thêm helper `_as_datetime()` chuẩn hoá cả 2 trường hợp, tránh crash khi driver/backend khác nhau trả kiểu khác nhau.
- `backend/app/services/trade_executor.py`: Kelly sizing giờ ưu tiên `rates.get(f"__symbol__{symbol}", dir_rate)` thay vì luôn dùng `dir_rate`.
**Test mới:** `tests/test_signal_booster.py` (file mới) — symbol đủ dữ liệu có rate riêng; symbol thiếu dữ liệu không có rate riêng (fallback đúng); nhiều symbol không lẫn rate của nhau; rate theo direction/overall vẫn tính đúng song song. `tests/test_trade_executor.py`, class `TestKellySymbolSpecificWinRate`.
### 3.2 (ss) Log rõ lỗi thay vì nuốt âm thầm
**Vấn đề:** Volatility filter (`except Exception: pass`) và Kelly sizing (`except Exception: logger.debug(...)`) nuốt mọi lỗi mà không để lại dấu vết hữu ích — nếu logic sizing bị lỗi ngầm trong production (dữ liệu rates sai định dạng, lỗi Decimal...), sẽ không ai biết cho đến khi phát hiện qua triệu chứng gián tiếp.
**Đã sửa** (`backend/app/services/trade_executor.py`): cả 2 khối đổi sang `logger.warning(...)` kèm nội dung exception cụ thể và symbol liên quan. Vẫn giữ hành vi fail-open (không chặn trade khi lỗi phụ trợ này xảy ra) — chỉ thay đổi mức độ hiển thị của lỗi.
### 3.3 (nn) Sửa `compute_partial_tp_levels` cho khớp docstring
**Vấn đề:** Docstring hứa 3 mức: TP1 25% + TP2 35% + phần còn lại 40% dùng trailing stop — nhưng code chỉ trả về 2 phần tử (60% tổng), không có phần tử thứ 3, khiến caller hiểu nhầm đã xử lý hết 100% khối lượng.
**Đã sửa** (`backend/app/services/risk_manager.py`): thêm phần tử thứ 3 vào kết quả trả về: `{"price": None, "close_percentage": 0.40, "trailing": True}` — đại diện phần còn lại giao cho trailing stop (không có giá cố định vì bản chất là quyết định runtime, không phải hàm tính giá tĩnh). 2 phần tử đầu thêm field `"trailing": False` để nhất quán.
**Test cập nhật** (`tests/test_risk_manager.py`, class `TestAdaptiveSLTPOptimizerPartialTpLevels`): xác nhận trả về đúng 3 phần tử, tổng `close_percentage` = 1.0 chính xác; 2 phần tử đầu có giá cố định + `trailing=False`, phần tử thứ 3 có `price=None` + `trailing=True` + đúng 40%.
### 3.4 (oo) Wire `compute_volatility_adjusted_size` vào luồng thực thi
**Vấn đề:** Hàm này (điều chỉnh size theo ATR% + regime) được viết hoàn chỉnh, có test, nhưng chưa từng được gọi ở đâu trong code sản phẩm — bộ lọc volatility hiện tại chỉ là "gate nhị phân" (skip nếu ATR% > 8% hoặc < 0.5%), không có điều chỉnh dần dần ở khoảng giữa.
**Cân nhắc quan trọng trước khi wire:** hàm gốc thiết kế theo mô hình "% rủi ro trên tổng bankroll" (`risk_per_trade = base_size × max_risk_pct/100`) — với `base_size` là số dư tài khoản lớn (hàng nghìn USD). Nhưng hệ thống paper-trading hiện tại dùng `trade_size` cố định nhỏ (10-500 USD/lệnh), nên nếu gọi hàm với `max_risk_pct` mặc định 2% theo đúng nghĩa gốc, kết quả luôn nhỏ hơn sàn $5 đã có sẵn (`max(trade_size, Decimal("5"))`) → tích hợp sẽ hoàn toàn vô tác dụng (luôn bị floor về $5 bất kể volatility).
**Giải pháp:** gọi hàm với `max_risk_pct=Decimal("100")` — tức diễn giải lại tham số này thành "áp dụng 100% hệ số điều chỉnh vol/regime lên trade_size đã tính (từ Kelly)" thay vì "% bankroll". Cách này tái sử dụng đúng công thức `vol_factor × regime_factor` đã có (không sửa hàm gốc) nhưng khớp đúng thang tiền thực tế của pipeline này. `atr_pct` lấy từ chính giá trị đã tính trong volatility filter (không tính lại), `regime` đọc từ `market_regime` đã lưu trong `indicators_snapshot` của signal.
**Test mới** (`tests/test_trade_executor.py`, class `TestVolatilityRegimeSizeAdjustment`): regime "volatile" + ATR% cao → size nhỏ hơn hẳn regime "neutral" + ATR% thấp; không có dữ liệu ATR trong snapshot → không ảnh hưởng gì (không lỗi, không thay đổi hành vi cũ).
### 3.5 (xx) Walk-forward: fallback chọn combo tốt nhất trong số các combo tệ, không phải combo đầu tiên
**Vấn đề:** Khi MỌI tổ hợp tham số trong 1 fold đều có `_fold_score = -inf` (quá ít lệnh để tin cậy), code cũ luôn chọn `param_grid[k][0]` — phần tử ĐẦU TIÊN của lưới tham số một cách tùy ý, có thể vô tình là tổ hợp tệ nhất.
**Đã sửa** (`backend/app/services/walk_forward.py`): `_grid_search()` giờ theo dõi song song 2 ứng viên trong CÙNG một lượt duyệt (không chạy lại): (1) combo tốt nhất theo `_fold_score` như trước, và (2) combo "ít tệ nhất" trong số các combo `-inf`, xếp hạng theo `(số lệnh, tổng PnL)` — ưu tiên combo có nhiều lệnh hơn (gần với ngưỡng tin cậy thống kê hơn), PnL làm tiêu chí phụ khi hòa. Chỉ dùng ứng viên (2) khi ứng viên (1) không tồn tại (mọi combo đều -inf).
**Test mới** (`tests/test_walk_forward.py`): fallback chọn đúng combo có nhiều lệnh hơn thay vì combo đầu tiên trong lưới; khi số lệnh bằng nhau, chọn đúng combo có PnL cao hơn làm tiêu chí phụ.
### 3.6 (rr) Nâng `MIN_TRADES_PER_FOLD` từ 5 lên 15
**Vấn đề:** Ngưỡng tối thiểu 5 lệnh để tin cậy điểm số kiểu Sharpe là khá thấp về mặt thống kê — vài lệnh ngoại lệ có thể làm méo mean/std đáng kể.
**Đã sửa** (`backend/app/services/walk_forward.py`): nâng `MIN_TRADES_PER_FOLD = 5` → `15`. Không nâng lên mức ~30 như khuyến nghị quant lý tưởng vì với hệ thống chỉ vào lệnh khi có tín hiệu STRONG (chọn lọc), yêu cầu 30 lệnh/fold có nguy cơ khiến hầu hết các fold không có tổ hợp tham số nào "đáng tin cậy" — 15 là điểm cân bằng giữa thận trọng thống kê và khả năng walk-forward thực sự chạy được với đủ số fold.
### 3.7 Kiểm chứng
Toàn bộ 219 test backend pass (tăng từ 209 ở v11 lên 219 — thêm 4 test symbol win-rate mới (`test_signal_booster.py`), 1 test Kelly theo symbol, 2 test volatility/regime sizing, 2 test walk-forward fallback ranking, cộng điều chỉnh test cũ của `compute_partial_tp_levels` và `_fold_score`/`test_grid_search_picks_the_best_scoring_combo` cho khớp ngưỡng/hành vi mới). Không đổi gì ở frontend trong đợt này.
---
## 4. Danh sách vấn đề còn tồn đọng
### Nhóm B — điều chỉnh tham số, cần thực nghiệm để kiểm chứng (chưa làm, người dùng chưa yêu cầu)
| # | Vấn đề | Vị trí |
|---|---|---|
| pp | Ichimoku dùng nguyên bộ 9-26-52-26 của lịch giao dịch Nhật cổ, không có cơ sở cho crypto 24/7 | `indicator_service.py:583-599` |
| qq | FVG thiếu bộ lọc kích thước gap theo ATR — sinh nhiều tín hiệu nhiễu ở khung 15m | `indicator_service.py:1334-1377` |
| uu | BOS buffer 0.3% cố định nên đổi theo ATR thay vì % tuyệt đối | `indicator_service.py:760,762` |
### Nhóm C — cần chạy walk-forward thực nghiệm, không phải sửa code (chưa làm)
| # | Vấn đề | Ghi chú |
|---|---|---|
| tt | Toàn bộ tham số kinh điển của 13 thuật toán (RSI-14, MACD 12-26-9, BB-20, SuperTrend 10/3.0...) là giá trị sách giáo khoa cho cổ phiếu/forex daily, chưa qua walk-forward riêng cho crypto | `indicator_service.py` (toàn bộ) — cần một quy trình so sánh thực nghiệm bộ tham số cũ vs mới trên dữ liệu lịch sử thật, không thể chỉ đoán số mới |
### Ưu tiên THẤP còn lại (chưa đổi từ v10/v11)
| # | Vấn đề | Vị trí |
|---|---|---|
| vv | Order Block detection quá đơn giản (thiếu điều kiện volume/imbalance) | `indicator_service.py:805-851` |
| ww | Ngưỡng candlestick pattern (Doji/Marubozu/Hammer...) chưa kiểm định theo winrate thực tế trên crypto | `indicator_service.py:1432-1497` |
### Đã xác nhận là scope-lớn, cần quyết định riêng (không phải bug)
| # | Vấn đề | Ghi chú |
|---|---|---|
| yy | Backtest chỉ mô phỏng 1 vị thế/symbol — chưa mô phỏng eviction đa vị thế (MAX_OPEN_TRADES=10) qua nhiều symbol đồng thời | Cần một portfolio-backtest engine mới — người dùng đã xác nhận KHÔNG làm, để dành quyết định riêng nếu cần |
---
## 5. Lịch sử phiên bản
| Phiên bản | Ngày | Thay đổi |
|---|---|---|
| v0-v9 | 2026-07-03 — 2026-07-04 | Xem file tương ứng |
| v10 | 2026-07-04 | Audit 13 thuật toán + risk/execution. Fee+slippage trong backtest/WFO, FIFO PnL thật, xác nhận độ trễ pivot. 187 test pass. |
| v11 | 2026-07-04 | (ii) SL/TP theo ATR trong backtest, (hh) Kelly correlation danh mục, (jj) correlation dampening theo cặp, (kk) ngưỡng volatile thích ứng, (ll) sửa race condition eviction. 209 test pass. |
| v12 | 2026-07-04 | Nhóm A: (mm) Kelly theo symbol, (ss) log lỗi rõ ràng, (nn) sửa `compute_partial_tp_levels`, (oo) wire `compute_volatility_adjusted_size`, (xx) fallback grid search chọn combo tốt nhất, (rr) nâng `MIN_TRADES_PER_FOLD` 5→15. 219 test pass (+10). Còn lại: Nhóm B (pp/qq/uu, cần thực nghiệm), Nhóm C (tt, cần walk-forward thật), vv/ww (thấp), yy (scope lớn, để riêng). |