Fix O(n^2) blowup and look-ahead leak in SMC/divergence backtest scoring

market_structure() (SMC) and detect_divergence() were each precomputed
once over the ENTIRE multi-year backtest range and reused unchanged for
every candle, so every candle's score could see results derived from
years of future price data — a look-ahead bug that inflated both
single-run backtest and walk-forward results, undermining the very
overfitting check walk-forward exists to provide. A prior fix bounded
this to a per-candle trailing window, which closed most of the leak but
still rescanned pivots from scratch on every candle (O(window) per
candle), too slow to enable 15m/30m walk-forward runs.

The real fix: pivot detection is itself a bounded rolling-window scan
(each position only depends on a few bars on either side), so it can be
precomputed once for the whole dataset just like BB/RSI/MACD. Per candle,
_compute_scores_series now just advances a monotonic pointer over
already-known pivots to whatever is causally confirmable as of that
candle — O(1) amortized across the whole run instead of O(window) or
O(n) per candle. Added an optional precomputed_pivots param to
detect_divergence() (backward compatible) to reuse this for RSI/MACD
divergence too.

Net effect: 16,000 candles went from 16.1s to 1.7s (confirmed empirically,
on top of an earlier ~10x from fixing the raw O(n^2)), and scaling stays
linear at 32,000 candles (3.2s). Walk-forward's timeframe options are now
15m/30m/1h/4h/1d (up from 1h/4h/1d) since 15m at the 3-year default
lookback now costs roughly 30s instead of 5+ minutes. Also wired
walk_forward.py's grid search to actually reuse one computed score series
across all 27 parameter combinations per fold (it was recomputing full
classification for every combination despite the scoring/threshold split
added earlier). 156 backend tests passing (3 new: causal-score regression,
pivot-detection-runs-once, order-block-window-bounded).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Le
2026-07-04 10:42:32 +07:00
parent 56325113b3
commit 1c022264f5
8 changed files with 476 additions and 131 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ router = APIRouter(prefix="/walk-forward", tags=["walk_forward"])
async def run(
symbol: str = Query("BTC/USDT"),
exchange: str = Query("mexc"),
timeframe: str = Query("4h", description="1h or 4h recommended — lower timeframes multiply the candle count and runtime"),
timeframe: str = Query("4h", description="15m/30m/1h/4h/1d — 1w/1M don't have enough candles over a multi-year lookback to form meaningful folds"),
total_days: int = Query(1095, ge=180, le=1825, description="Total lookback in days (default ~3 years)"),
train_days: int = Query(270, ge=30, description="Train window size per fold, in days"),
test_days: int = Query(90, ge=14, description="Held-out test window size per fold, in days"),
+274 -74
View File
@@ -20,16 +20,66 @@ from app.models.symbol import Symbol
from app.models.exchange import Exchange
from app.services.indicator_service import (
bollinger_bands, rsi, sma, macd, supertrend,
volume_breakout, ichimoku, detect_divergence, market_structure,
volume_breakout, ichimoku, detect_divergence,
_find_pivot_highs, _find_pivot_lows,
_find_pivot_highs_levels, _find_pivot_lows_levels,
_detect_bos, _detect_choch, _detect_order_blocks,
)
from app.services.signal_scoring import (
_classify_signal_combined,
_compute_adjusted_score,
_score_to_signal,
STRONG_BUY, BUY, STRONG_SELL, SELL,
)
# Min candles for warmup: BB(20) + RSI(14) + some room = 30
MIN_CANDLES = 30
# Bounded trailing-window sizes used when replaying indicators per candle.
# Every consumer in signal_scoring.py only ever reads the last 1-2 elements
# of these arrays except the BB squeeze check (lookback=10), so windows
# a little larger than that are enough — the point is each candle's cost
# becomes O(window), not O(candle_index), which is what made the old
# "slice the whole precomputed array up to now" approach O(n^2) overall.
_BB_WINDOW = 20
_SHORT_WINDOW = 3
# 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/
# order_blocks) once over the *entire* multi-year backtest range and
# reused it unchanged for every candle, so a candle's score could see
# results derived from years of future data. Recomputing a fresh
# snapshot per candle on a bounded trailing window fixed the leak but
# was still O(window) of real pivot-scanning work per candle.
#
# The actual fix: pivot detection (_find_pivot_highs[_levels],
# _find_pivot_lows[_levels]) is itself a bounded rolling-window scan
# (each position only depends on `pivot_lookback` bars on either side),
# so it can be precomputed ONCE over the whole dataset just like BB/RSI/
# etc — see _precompute_indicators. Per candle, _compute_scores_series
# then only needs to know which of those precomputed pivots are already
# *confirmable* using data up to that candle (a pivot at position p needs
# `pivot_lookback` bars after p to confirm — a small, bounded, and
# inherent-to-the-indicator delay, not a look-ahead bug) — tracked with a
# monotonically advancing pointer + append-only list per candle, i.e.
# O(1) amortized across the whole run, not O(window) or O(n) per candle.
_PIVOT_LOOKBACK = 3 # matches market_structure()'s pivot_lookback default
_DIVERGENCE_PIVOT_LOOKBACK = 5 # matches detect_divergence()'s pivot_lookback default
_ORDER_BLOCK_LOOKBACK = 40 # matches the lookback backtest always requested from market_structure() before
def _tail(v, end_idx: int, window: int):
"""Trailing slice of `v` ending at `end_idx` (inclusive), capped at
`window` elements — O(window), not O(end_idx)."""
if v is None or not hasattr(v, "__getitem__"):
return v
start = max(0, end_idx + 1 - window)
return v[start:end_idx + 1]
def _tail_dict(d: dict, end_idx: int, window: int) -> dict:
return {k: _tail(v, end_idx, window) for k, v in d.items()} if d else {}
async def _fetch_symbol(db: AsyncSession, symbol: str, exchange: str) -> Symbol | None:
"""Look up a Symbol row by (symbol, exchange) name."""
@@ -88,7 +138,11 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
]
close_prices_full = [float(c.close) for c in candles]
# Pre-compute indicators on full dataset (O(n) instead of O(n²))
# Pre-compute rolling-window indicators on the full dataset ONCE — these
# are genuinely causal per-candle arrays (arr[:i+1] truthfully represents
# "value as of candle i"), so precomputing once and taking bounded
# trailing windows per candle (see _compute_scores_series) is both
# correct and fast.
bb_full = bollinger_bands(close_prices_full) or {}
rsi_full = rsi(close_prices_full) or []
sma_full = sma(close_prices_full, 20) or []
@@ -96,12 +150,16 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
st_full = supertrend(candle_dicts_full) or {}
vb_full = volume_breakout(candle_dicts_full) or []
ichi_full = ichimoku(candle_dicts_full) or {}
smc_full = market_structure(candle_dicts_full) or {}
# Pre-compute divergence ONCE (uses full arrays, indexes match)
rsi_div_full = detect_divergence(close_prices_full, rsi_full)
macd_hist_full = macd_full.get("histogram", []) if macd_full else []
macd_div_full = detect_divergence(close_prices_full, macd_hist_full)
# SMC (market_structure) and divergence detection are pivot-based, not
# rolling-window — but pivot detection itself IS a bounded rolling scan
# (see _PIVOT_LOOKBACK/_DIVERGENCE_PIVOT_LOOKBACK above), so precompute
# it once too. _compute_scores_series turns these into causally-
# confirmed running lists rather than reusing them wholesale.
swing_highs_full = _find_pivot_highs_levels(close_prices_full, _PIVOT_LOOKBACK, _PIVOT_LOOKBACK)
swing_lows_full = _find_pivot_lows_levels(close_prices_full, _PIVOT_LOOKBACK, _PIVOT_LOOKBACK)
price_pivot_highs_full = _find_pivot_highs(close_prices_full, _DIVERGENCE_PIVOT_LOOKBACK, _DIVERGENCE_PIVOT_LOOKBACK)
price_pivot_lows_full = _find_pivot_lows(close_prices_full, _DIVERGENCE_PIVOT_LOOKBACK, _DIVERGENCE_PIVOT_LOOKBACK)
# Pre-build MTF candles ONCE per MTF config
mtf_precomputed = []
@@ -131,7 +189,8 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
"st": supertrend(mtf_candles_list) or {},
"vb": volume_breakout(mtf_candles_list) or [],
"ichi": ichimoku(mtf_candles_list) or {},
"smc": market_structure(mtf_candles_list) or {},
"swing_highs": _find_pivot_highs_levels(mtf_p, _PIVOT_LOOKBACK, _PIVOT_LOOKBACK),
"swing_lows": _find_pivot_lows_levels(mtf_p, _PIVOT_LOOKBACK, _PIVOT_LOOKBACK),
})
return {
@@ -144,30 +203,35 @@ def _precompute_indicators(candles: list[Candle], timeframe: str) -> dict:
"st_full": st_full,
"vb_full": vb_full,
"ichi_full": ichi_full,
"smc_full": smc_full,
"rsi_div_full": rsi_div_full,
"macd_div_full": macd_div_full,
"swing_highs_full": swing_highs_full,
"swing_lows_full": swing_lows_full,
"price_pivot_highs_full": price_pivot_highs_full,
"price_pivot_lows_full": price_pivot_lows_full,
"mtf_precomputed": mtf_precomputed,
}
def _simulate_trades(
def _compute_scores_series(
candles: list[Candle],
precomputed: dict,
trade_size: Decimal = Decimal("10"),
strong_threshold: float = 4.0,
signal_threshold: float = 1.0,
max_hold_candles: int = 48,
active_from_index: int = MIN_CANDLES,
) -> tuple[list[dict], list[dict]]:
"""Replay signal classification + trade simulation over precomputed indicators.
) -> list[dict]:
"""Compute the threshold-independent adjusted score for every candle
from active_from_index onward.
This is the expensive half of classification — the 13-algorithm vote —
split out so callers trying many threshold combinations against the
same data (walk-forward grid search) can run it ONCE per fold and
cheaply replay `_score_to_signal` against the result, instead of
re-running the vote for every combination.
`active_from_index` lets callers pass extra warmup candles before the
window they actually want simulated (e.g. walk-forward fold
boundaries) — candles before this index are used only so indicators
have enough lookback, never turned into signals/trades.
window they actually want scored (e.g. walk-forward fold boundaries)
— candles before this index exist only so indicators have enough
lookback, never turned into signals/trades.
"""
close_prices_full = precomputed["close_prices_full"]
candle_dicts_full = precomputed["candle_dicts_full"]
bb_full = precomputed["bb_full"]
rsi_full = precomputed["rsi_full"]
sma_full = precomputed["sma_full"]
@@ -175,79 +239,197 @@ def _simulate_trades(
st_full = precomputed["st_full"]
vb_full = precomputed["vb_full"]
ichi_full = precomputed["ichi_full"]
smc_full = precomputed["smc_full"]
rsi_div_full = precomputed["rsi_div_full"]
macd_div_full = precomputed["macd_div_full"]
swing_highs_full = precomputed["swing_highs_full"]
swing_lows_full = precomputed["swing_lows_full"]
price_pivot_highs_full = precomputed["price_pivot_highs_full"]
price_pivot_lows_full = precomputed["price_pivot_lows_full"]
mtf_precomputed = precomputed["mtf_precomputed"]
all_signals = []
trades = []
current_position = None
macd_hist_full = macd_full.get("histogram") if macd_full else None
start_index = max(MIN_CANDLES, active_from_index)
results: list[dict] = []
# Running, causally-confirmed pivot state — advanced monotonically as
# `i` increases, so the total work across the whole loop is O(n), not
# O(n) *per candle*. A pivot at raw position p is only "confirmed" (its
# value can influence a candle's score) once `pivot_lookback` bars after
# it are known, i.e. once i >= p + pivot_lookback — this is a small,
# bounded delay inherent to how pivots are defined (the same delay a
# live-trading consumer would see), not a look-ahead leak.
swing_ptr = 0
confirmed_swing_highs: list[float] = []
confirmed_swing_lows: list[float] = []
div_ptr = 0
confirmed_div_high_idx: list[int] = []
confirmed_div_low_idx: list[int] = []
# Per-MTF-timeframe running state, keyed by position in mtf_precomputed.
mtf_swing_state = [
{"ptr": 0, "highs": [], "lows": []} for _ in mtf_precomputed
]
for i in range(start_index, len(candles)):
candle = candles[i]
latest_close = close_prices_full[i]
timestamp = candle.timestamp.isoformat()
bb_data = _tail_dict(bb_full, i, _BB_WINDOW)
rsi_data = _tail(rsi_full, i, _SHORT_WINDOW) or []
sma_data = _tail(sma_full, i, _SHORT_WINDOW) or []
macd_data = _tail_dict(macd_full, i, _SHORT_WINDOW)
st_data = _tail_dict(st_full, i, _SHORT_WINDOW)
vb_data = _tail(vb_full, i, _SHORT_WINDOW) or []
ichi_data = _tail_dict(ichi_full, i, _SHORT_WINDOW)
# Slice pre-computed arrays (O(i) but ~100x faster than recomputing)
clip = i + 1
def _safe_slice(v):
return v[:clip] if v is not None and hasattr(v, '__getitem__') else v
bb_data = {k: _safe_slice(v) for k, v in bb_full.items()} if bb_full else {}
rsi_data = rsi_full[:clip] if rsi_full else []
sma_data = sma_full[:clip] if sma_full else []
macd_data = {k: _safe_slice(v) for k, v in macd_full.items()} if macd_full else {}
st_data = {k: _safe_slice(v) for k, v in st_full.items()} if st_full else {}
vb_data = vb_full[:clip] if vb_full else []
ichi_data = {k: _safe_slice(v) for k, v in ichi_full.items()} if ichi_full else {}
smc_data = {k: _safe_slice(v) for k, v in smc_full.items()} if smc_full else {}
# Advance the swing/pivot confirmation pointers to whatever is
# knowable as of candle i (see comment above).
swing_confirm_limit = i - _PIVOT_LOOKBACK + 1
while swing_ptr < swing_confirm_limit:
if swing_highs_full[swing_ptr] is not None:
confirmed_swing_highs.append(swing_highs_full[swing_ptr])
if swing_lows_full[swing_ptr] is not None:
confirmed_swing_lows.append(swing_lows_full[swing_ptr])
swing_ptr += 1
# MTF votes — use precomputed MTF indicators, sliced to current MTF candle index
div_confirm_limit = i - _DIVERGENCE_PIVOT_LOOKBACK + 1
while div_ptr < div_confirm_limit:
if price_pivot_highs_full[div_ptr] is not None:
confirmed_div_high_idx.append(div_ptr)
if price_pivot_lows_full[div_ptr] is not None:
confirmed_div_low_idx.append(div_ptr)
div_ptr += 1
# SMC (BOS/CHoCH/trend/order-blocks) from the causally-confirmed
# swings above, instead of rescanning raw candles for pivots.
recent_highs = confirmed_swing_highs[-3:]
recent_lows = confirmed_swing_lows[-3:]
bos = _detect_bos(recent_highs, recent_lows, [close_prices_full[i]])
choch = _detect_choch(
confirmed_swing_highs[-5:], confirmed_swing_lows[-5:],
close_prices_full[max(0, i - 19):i + 1],
)
ob_start = max(0, i + 1 - _ORDER_BLOCK_LOOKBACK - 3)
obs = _detect_order_blocks(candle_dicts_full[ob_start:i + 1], lookback=_ORDER_BLOCK_LOOKBACK)
trend = "NEUTRAL"
if len(recent_highs) >= 2 and len(recent_lows) >= 2:
if recent_highs[-1] > recent_highs[-2] and recent_lows[-1] > recent_lows[-2]:
trend = "BULLISH"
elif recent_highs[-1] < recent_highs[-2] and recent_lows[-1] < recent_lows[-2]:
trend = "BEARISH"
smc_data = {"bos": bos, "choch": choch, "order_blocks": obs, "trend": trend}
# Divergence — same causally-confirmed pivots feed both the RSI-
# divergence and MACD-histogram-divergence checks (both key off
# the same price pivots, just a different indicator series).
div_highs = confirmed_div_high_idx[-3:]
div_lows = confirmed_div_low_idx[-3:]
rsi_div = detect_divergence(close_prices_full, rsi_full, precomputed_pivots=(div_highs, div_lows)) if rsi_full else (None, None)
macd_div = detect_divergence(close_prices_full, macd_hist_full, precomputed_pivots=(div_highs, div_lows)) if macd_hist_full else (None, None)
# MTF votes — same causally-confirmed-swing treatment per sub-timeframe.
mtf_votes = []
for mtf in mtf_precomputed:
# Which MTF candle corresponds to main candle i?
for mtf, state in zip(mtf_precomputed, mtf_swing_state):
mtf_idx = i // mtf["mult"]
if mtf_idx < MIN_CANDLES or mtf_idx >= len(mtf["close_prices"]):
continue
clip_mtf = mtf_idx + 1
def _safe_slice_mtf(v):
return v[:clip_mtf] if v is not None and hasattr(v, '__getitem__') else v
mtf_swing_highs_full = mtf["swing_highs"]
mtf_swing_lows_full = mtf["swing_lows"]
mtf_confirm_limit = mtf_idx - _PIVOT_LOOKBACK + 1
while state["ptr"] < mtf_confirm_limit:
p = state["ptr"]
if mtf_swing_highs_full[p] is not None:
state["highs"].append(mtf_swing_highs_full[p])
if mtf_swing_lows_full[p] is not None:
state["lows"].append(mtf_swing_lows_full[p])
state["ptr"] += 1
mtf_recent_highs = state["highs"][-3:]
mtf_recent_lows = state["lows"][-3:]
mtf_close = mtf["close_prices"][mtf_idx]
mtf_bos = _detect_bos(mtf_recent_highs, mtf_recent_lows, [mtf_close])
mtf_choch = _detect_choch(
state["highs"][-5:], state["lows"][-5:],
mtf["close_prices"][max(0, mtf_idx - 19):mtf_idx + 1],
)
mtf_ob_start = max(0, mtf_idx + 1 - _ORDER_BLOCK_LOOKBACK - 3)
mtf_obs = _detect_order_blocks(mtf["candles_list"][mtf_ob_start:mtf_idx + 1], lookback=_ORDER_BLOCK_LOOKBACK)
mtf_trend = "NEUTRAL"
if len(mtf_recent_highs) >= 2 and len(mtf_recent_lows) >= 2:
if mtf_recent_highs[-1] > mtf_recent_highs[-2] and mtf_recent_lows[-1] > mtf_recent_lows[-2]:
mtf_trend = "BULLISH"
elif mtf_recent_highs[-1] < mtf_recent_highs[-2] and mtf_recent_lows[-1] < mtf_recent_lows[-2]:
mtf_trend = "BEARISH"
mtf_smc = {"bos": mtf_bos, "choch": mtf_choch, "order_blocks": mtf_obs, "trend": mtf_trend}
mtf_s, *_ = _classify_signal_combined(
mtf["close_prices"][mtf_idx],
{k: _safe_slice_mtf(v) for k, v in mtf["bb"].items()},
mtf["rsi"][:clip_mtf],
mtf["sma"][:clip_mtf],
{k: _safe_slice_mtf(v) for k, v in mtf["macd"].items()} if mtf["macd"] else None,
{k: _safe_slice_mtf(v) for k, v in mtf["st"].items()} if mtf["st"] else None,
mtf["vb"][:clip_mtf] if mtf["vb"] else None,
{k: _safe_slice_mtf(v) for k, v in mtf["ichi"].items()} if mtf["ichi"] else None,
mtf_close,
_tail_dict(mtf["bb"], mtf_idx, _BB_WINDOW),
_tail(mtf["rsi"], mtf_idx, _SHORT_WINDOW) or [],
_tail(mtf["sma"], mtf_idx, _SHORT_WINDOW) or [],
_tail_dict(mtf["macd"], mtf_idx, _SHORT_WINDOW),
_tail_dict(mtf["st"], mtf_idx, _SHORT_WINDOW),
_tail(mtf["vb"], mtf_idx, _SHORT_WINDOW) or [],
_tail_dict(mtf["ichi"], mtf_idx, _SHORT_WINDOW),
(None, None), (None, None),
{k: _safe_slice_mtf(v) for k, v in mtf["smc"].items()} if mtf["smc"] else None,
mtf_smc,
)
if mtf_s:
mtf_votes.append((mtf_s, "", mtf["weight"]))
signal_type, strength, *_ = _classify_signal_combined(
latest_close, bb_data, rsi_data, sma_data,
override_signal, override_strength, adjusted_score, confidence, _raw_scores = _compute_adjusted_score(
close_prices_full[i], bb_data, rsi_data, sma_data,
macd_data, st_data, vb_data, ichi_data,
rsi_div_full, macd_div_full, smc_data, mtf_votes or None,
strong_threshold=strong_threshold, signal_threshold=signal_threshold,
rsi_div, macd_div, smc_data, mtf_votes or None,
)
results.append({
"index": i,
"timestamp": candles[i].timestamp.isoformat(),
"close": close_prices_full[i],
"override_signal": override_signal,
"override_strength": override_strength,
"adjusted_score": adjusted_score,
"confidence": confidence,
})
return results
def _simulate_from_scores(
candles: list[Candle],
scores_series: list[dict],
trade_size: Decimal = Decimal("10"),
strong_threshold: float = 4.0,
signal_threshold: float = 1.0,
max_hold_candles: int = 48,
) -> tuple[list[dict], list[dict]]:
"""Cheap half of simulation: turn a precomputed score series into
signals + trades for one choice of thresholds.
See `_compute_scores_series` for the expensive half — run once,
reused across every threshold combination a grid search tries.
"""
all_signals: list[dict] = []
trades: list[dict] = []
current_position = None
for entry in scores_series:
i = entry["index"]
timestamp = entry["timestamp"]
latest_close = entry["close"]
if entry["override_signal"] is not None:
signal_type, strength = entry["override_signal"], entry["override_strength"]
else:
signal_type, strength = _score_to_signal(entry["adjusted_score"], strong_threshold, signal_threshold)
if signal_type:
all_signals.append({
"time": timestamp, "signal": signal_type,
"strength": strength or "", "price": latest_close,
})
# PnL simulation
if signal_type in (STRONG_BUY, BUY):
if current_position and current_position["direction"] == "SHORT":
if signal_type == STRONG_BUY:
entry = current_position["entry_price"]
entry_price = current_position["entry_price"]
qty = current_position["quantity"]
pnl = (entry - latest_close) * qty
pnl = (entry_price - latest_close) * qty
current_position.update({
"exit_price": latest_close, "exit_time": timestamp,
"pnl": pnl, "status": "CLOSED", "exit_reason": "REVERSAL",
@@ -267,9 +449,9 @@ def _simulate_trades(
elif signal_type in (STRONG_SELL, SELL):
if current_position and current_position["direction"] == "LONG":
if signal_type == STRONG_SELL:
entry = current_position["entry_price"]
entry_price = current_position["entry_price"]
qty = current_position["quantity"]
pnl = (latest_close - entry) * qty
pnl = (latest_close - entry_price) * qty
current_position.update({
"exit_price": latest_close, "exit_time": timestamp,
"pnl": pnl, "status": "CLOSED", "exit_reason": "REVERSAL",
@@ -286,16 +468,15 @@ def _simulate_trades(
"entry_signal": signal_type, "entry_index": i, "status": "OPEN",
}
# Time limit
if current_position and current_position["status"] == "OPEN":
hold = i - current_position["entry_index"]
if hold >= max_hold_candles:
entry = current_position["entry_price"]
entry_price = current_position["entry_price"]
qty = current_position["quantity"]
if current_position["direction"] == "LONG":
pnl = (latest_close - entry) * qty
pnl = (latest_close - entry_price) * qty
else:
pnl = (entry - latest_close) * qty
pnl = (entry_price - latest_close) * qty
current_position.update({
"exit_price": latest_close, "exit_time": timestamp,
"pnl": pnl, "status": "CLOSED", "exit_reason": "TIME_LIMIT",
@@ -306,12 +487,12 @@ def _simulate_trades(
# Close final position
if current_position and current_position["status"] == "OPEN":
last_close = float(candles[-1].close)
entry = current_position["entry_price"]
entry_price = current_position["entry_price"]
qty = current_position["quantity"]
if current_position["direction"] == "LONG":
pnl = (last_close - entry) * qty
pnl = (last_close - entry_price) * qty
else:
pnl = (entry - last_close) * qty
pnl = (entry_price - last_close) * qty
current_position.update({
"exit_price": last_close,
"exit_time": candles[-1].timestamp.isoformat(),
@@ -322,6 +503,25 @@ def _simulate_trades(
return all_signals, trades
def _simulate_trades(
candles: list[Candle],
precomputed: dict,
trade_size: Decimal = Decimal("10"),
strong_threshold: float = 4.0,
signal_threshold: float = 1.0,
max_hold_candles: int = 48,
active_from_index: int = MIN_CANDLES,
) -> tuple[list[dict], list[dict]]:
"""Convenience wrapper: compute scores then simulate with one set of
thresholds. Callers trying many threshold combinations against the
same data (walk-forward grid search) should call
`_compute_scores_series` once and `_simulate_from_scores` per
combination instead — see walk_forward.py.
"""
scores_series = _compute_scores_series(candles, precomputed, active_from_index)
return _simulate_from_scores(candles, scores_series, trade_size, strong_threshold, signal_threshold, max_hold_candles)
def _compute_stats(all_signals: list[dict], trades: list[dict]) -> dict:
"""Reduce raw signals/trades into the summary stats block used by
both the single-run backtest and each walk-forward fold."""
+12 -2
View File
@@ -642,12 +642,19 @@ def detect_divergence(
prices: list[float],
indicator: list[Optional[float]],
pivot_lookback: int = 5,
precomputed_pivots: tuple[list[Optional[int]], list[Optional[int]]] | None = None,
) -> tuple[Optional[str], Optional[str]]:
"""Detect regular and hidden divergences using up to 3 pivot points.
Compares 2 or 3 pivot points in prices vs indicator for stronger signals.
Multi-pivot divergence (3+ pivots) returns STRONG; 2-pivot returns WEAK.
`precomputed_pivots`, if given, replaces the internal `_find_pivot_highs`/
`_find_pivot_lows` scan with an already-known (pivots_high, pivots_low)
pair — lets a caller that recomputes this per-candle over a large
dataset (e.g. backtesting) reuse pivots it tracked incrementally
instead of rescanning on every call.
Returns (divergence_type, strength):
- ("BULLISH", "STRONG") — Regular Bullish Divergence (2+ consecutive pairs)
- ("BULLISH", "WEAK") — Hidden Bullish Divergence or single regular
@@ -659,8 +666,11 @@ def detect_divergence(
if n < 20:
return None, None
pivots_high = _find_pivot_highs(prices, pivot_lookback, pivot_lookback)
pivots_low = _find_pivot_lows(prices, pivot_lookback, pivot_lookback)
if precomputed_pivots is not None:
pivots_high, pivots_low = precomputed_pivots
else:
pivots_high = _find_pivot_highs(prices, pivot_lookback, pivot_lookback)
pivots_low = _find_pivot_lows(prices, pivot_lookback, pivot_lookback)
low_indices = [i for i in pivots_low if i is not None and indicator[i] is not None][-3:]
high_indices = [i for i in pivots_high if i is not None and indicator[i] is not None][-3:]
+23 -15
View File
@@ -29,13 +29,16 @@ from app.services.backtest_engine import (
_fetch_symbol,
_fetch_candles,
_precompute_indicators,
_simulate_trades,
_compute_scores_series,
_simulate_from_scores,
_compute_stats,
)
# Parameters that are cheap to grid-search: they only affect the final
# threshold/exit logic, not the 13-algorithm scoring itself, so replaying
# them against already-precomputed indicators is fast (see _simulate_trades).
# threshold/exit logic, not the 13-algorithm scoring itself. Each fold's
# score series (see _compute_scores_series) is computed ONCE and replayed
# against every combination below via _simulate_from_scores, so the grid
# size mainly costs cheap arithmetic, not repeated indicator work.
DEFAULT_PARAM_GRID: dict[str, list[float]] = {
"strong_threshold": [3.5, 4.0, 4.5],
"signal_threshold": [0.75, 1.0, 1.5],
@@ -135,25 +138,28 @@ async def _prepare_window(
return candles, precomputed, active_from_index
def _run_combo(candles, precomputed, trade_size, params, active_from_index):
return _simulate_trades(
candles, precomputed, trade_size,
def _run_combo(candles, scores_series, trade_size, params):
"""Apply one parameter combination to an already-computed score series
(see `_compute_scores_series`) — cheap, no indicator recomputation."""
return _simulate_from_scores(
candles, scores_series, trade_size,
strong_threshold=params["strong_threshold"],
signal_threshold=params["signal_threshold"],
max_hold_candles=int(params["max_hold_candles"]),
active_from_index=active_from_index,
)
def _grid_search(
candles: list,
precomputed: dict,
scores_series: list[dict],
param_grid: dict[str, list[float]],
trade_size: Decimal,
active_from_index: int,
) -> tuple[dict[str, float], float, dict]:
"""Try every combination in param_grid, return the one that scores best
on the train window by `_fold_score`."""
"""Try every combination in param_grid against one precomputed score
series, return the one that scores best on the train window by
`_fold_score`. The 13-algorithm scoring itself already happened once
to produce `scores_series` — this loop only replays cheap threshold
comparisons and trade bookkeeping per combination."""
keys = list(param_grid.keys())
best_params: dict[str, float] | None = None
best_score = float("-inf")
@@ -161,7 +167,7 @@ def _grid_search(
for combo in product(*(param_grid[k] for k in keys)):
params = dict(zip(keys, combo))
all_signals, trades = _run_combo(candles, precomputed, trade_size, params, active_from_index)
all_signals, trades = _run_combo(candles, scores_series, trade_size, params)
closed_trades = [t for t in trades if t.get("status") == "CLOSED"]
score = _fold_score(closed_trades)
if score > best_score:
@@ -173,7 +179,7 @@ def _grid_search(
# Every combo scored -inf (too few trades) — still report the
# 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, precomputed, trade_size, best_params, active_from_index)
all_signals, trades = _run_combo(candles, scores_series, trade_size, best_params)
best_stats = _compute_stats(all_signals, trades)
return best_params, best_score, best_stats
@@ -225,17 +231,19 @@ async def run_walk_forward(
if train_window is None:
continue
train_candles, train_precomputed, train_active_from = train_window
train_scores = _compute_scores_series(train_candles, train_precomputed, train_active_from)
best_params, _train_score, train_stats = _grid_search(
train_candles, train_precomputed, param_grid, trade_size, train_active_from,
train_candles, train_scores, param_grid, trade_size,
)
test_window = await _prepare_window(db, db_symbol.id, timeframe, spec["test_start"], spec["test_end"])
if test_window is None:
continue
test_candles, test_precomputed, test_active_from = test_window
test_scores = _compute_scores_series(test_candles, test_precomputed, test_active_from)
test_signals, test_trades = _run_combo(test_candles, test_precomputed, trade_size, best_params, test_active_from)
test_signals, test_trades = _run_combo(test_candles, test_scores, trade_size, best_params)
test_stats = _compute_stats(test_signals, test_trades)
stitched_oos_trades.extend(t for t in test_trades if t.get("status") == "CLOSED")
+139 -28
View File
@@ -104,48 +104,52 @@ async def test_run_backtest_returns_expected_shape(db_session):
assert set(result["trades"].keys()) >= {"total", "wins", "losses", "win_rate", "total_pnl", "profit_factor"}
def _make_fake_classifier(buy_at: set[int], sell_at: set[int]):
"""Build a fake `_classify_signal_combined` that ignores indicator data
and instead signals BUY/SELL purely from `close_price`, which the real
test encodes as the candle index (so we can trigger deterministically).
Also records the strong_threshold/signal_threshold it was called with.
def _make_fake_score_fn(buy_at: set[int], sell_at: set[int], score: float = 10.0):
"""Build a fake `_compute_adjusted_score` that ignores indicator data
and instead reports a strong score purely from `close_price`, which the
real test encodes as the candle index (so triggers are deterministic).
`_compute_adjusted_score` is threshold-independent post-refactor — the
thresholds now live entirely in `_score_to_signal`/`_simulate_from_scores`.
"""
calls = []
calls: list[float] = []
def fake(close_price, *args, **kwargs):
calls.append({
"strong_threshold": kwargs.get("strong_threshold"),
"signal_threshold": kwargs.get("signal_threshold"),
})
calls.append(close_price)
idx = int(round(close_price))
if idx in buy_at:
return backtest_engine.STRONG_BUY, "STRONG", 0.9, {}
return None, None, score, 0.9, {}
if idx in sell_at:
return backtest_engine.STRONG_SELL, "STRONG", 0.9, {}
return None, None, 0.5, {}
return None, None, -score, 0.9, {}
return None, None, 0.0, 0.5, {}
return fake, calls
async def test_simulate_trades_passes_thresholds_to_classifier(monkeypatch, db_session):
async def test_simulate_trades_thresholds_control_signal_strength(monkeypatch, db_session):
"""Post-refactor, `_compute_adjusted_score` no longer takes thresholds
at all — `strong_threshold`/`signal_threshold` are applied afterwards
by `_score_to_signal`. This proves that pass-through still works: the
same fixed score classifies differently depending on the thresholds
given to `_simulate_trades`."""
_, symbol = await _seed_symbol(db_session)
base = datetime.now(timezone.utc) - timedelta(hours=40)
# close price == candle index, so the fake classifier can key off it
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")
fake, calls = _make_fake_classifier(buy_at={35}, sell_at=set())
monkeypatch.setattr(backtest_engine, "_classify_signal_combined", fake)
def fake_fixed_score(close_price, *args, **kwargs):
return None, None, 3.0, 0.9, {}
monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake_fixed_score)
backtest_engine._simulate_trades(
candles, precomputed, Decimal("10"),
strong_threshold=2.5, signal_threshold=0.5,
signals_default, _ = backtest_engine._simulate_trades(candles, precomputed, Decimal("10"))
assert len(signals_default) > 0
assert all(s["signal"] == backtest_engine.BUY for s in signals_default)
signals_lowered, _ = backtest_engine._simulate_trades(
candles, precomputed, Decimal("10"), strong_threshold=2.5,
)
assert len(calls) > 0
assert all(c["strong_threshold"] == 2.5 for c in calls)
assert all(c["signal_threshold"] == 0.5 for c in calls)
assert len(signals_lowered) > 0
assert all(s["signal"] == backtest_engine.STRONG_BUY for s in signals_lowered)
async def test_simulate_trades_exits_on_max_hold_candles(monkeypatch, db_session):
@@ -157,8 +161,8 @@ async def test_simulate_trades_exits_on_max_hold_candles(monkeypatch, db_session
# Open a LONG at index 30 (candle price 30) and never signal again —
# it must be force-closed exactly `max_hold_candles` candles later.
fake, _ = _make_fake_classifier(buy_at={30}, sell_at=set())
monkeypatch.setattr(backtest_engine, "_classify_signal_combined", fake)
fake, _ = _make_fake_score_fn(buy_at={30}, sell_at=set())
monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake)
_, trades = backtest_engine._simulate_trades(
candles, precomputed, Decimal("10"), max_hold_candles=5,
@@ -181,8 +185,8 @@ async def test_simulate_trades_active_from_index_skips_warmup_region(monkeypatch
# A BUY signal planted inside the warmup region (index 32, but
# active_from_index=35) must never open a trade.
fake, _ = _make_fake_classifier(buy_at={32}, sell_at=set())
monkeypatch.setattr(backtest_engine, "_classify_signal_combined", fake)
fake, _ = _make_fake_score_fn(buy_at={32}, sell_at=set())
monkeypatch.setattr(backtest_engine, "_compute_adjusted_score", fake)
all_signals, trades = backtest_engine._simulate_trades(
candles, precomputed, Decimal("10"), active_from_index=35,
@@ -190,3 +194,110 @@ async def test_simulate_trades_active_from_index_skips_warmup_region(monkeypatch
assert trades == []
assert all_signals == []
def _build_candle_series(base, prices):
return [
Candle(
symbol_id=1, timeframe="1h", timestamp=base + timedelta(hours=i),
open=Decimal(str(p)), high=Decimal(str(p * 1.01)), low=Decimal(str(p * 0.99)),
close=Decimal(str(p)), volume=Decimal("1000"),
)
for i, p in enumerate(prices)
]
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
range and reused unchanged for every candle, so a candle's score could
be influenced by prices that hadn't happened yet as of that candle.
This proves candle 90's score is identical whether candle 140 behaves
normally or crashes hard afterward — the future can no longer leak
backward into an earlier candle's classification.
"""
base = datetime.now(timezone.utc) - timedelta(hours=150)
shared_prefix = [100 + 5 * math.sin(i / 4) + (i % 5) for i in range(100)]
calm_tail = [shared_prefix[-1] + (i % 3) for i in range(1, 51)]
crash_tail = [shared_prefix[-1] - i * 3 for i in range(1, 51)]
candles_calm = _build_candle_series(base, shared_prefix + calm_tail)
candles_crash = _build_candle_series(base, shared_prefix + crash_tail)
precomputed_calm = backtest_engine._precompute_indicators(candles_calm, "1h")
precomputed_crash = backtest_engine._precompute_indicators(candles_crash, "1h")
scores_calm = backtest_engine._compute_scores_series(candles_calm, precomputed_calm)
scores_crash = backtest_engine._compute_scores_series(candles_crash, precomputed_crash)
# Candle 90 is well before the two futures diverge at candle 100 —
# its score must not depend on which future it's embedded in.
entry_calm = next(s for s in scores_calm if s["index"] == 90)
entry_crash = next(s for s in scores_crash if s["index"] == 90)
assert entry_calm["adjusted_score"] == entry_crash["adjusted_score"]
assert entry_calm["override_signal"] == entry_crash["override_signal"]
def test_pivot_detection_runs_once_not_per_candle(monkeypatch):
"""Regression test for the O(n^2) fix: pivot detection
(_find_pivot_highs[_levels]/_find_pivot_lows[_levels]) must run ONCE
over the whole dataset in `_precompute_indicators`, not be rescanned
for every candle in `_compute_scores_series` — that per-candle
rescanning (on top of it also leaking future data — see the causal
test above) is what made 15m/30m walk-forward runs too slow.
"""
call_counts = {"highs_levels": 0, "lows_levels": 0, "highs": 0, "lows": 0}
real_highs_levels = backtest_engine._find_pivot_highs_levels
real_lows_levels = backtest_engine._find_pivot_lows_levels
real_highs = backtest_engine._find_pivot_highs
real_lows = backtest_engine._find_pivot_lows
def spy(name, real_fn):
def wrapper(*args, **kwargs):
call_counts[name] += 1
return real_fn(*args, **kwargs)
return wrapper
monkeypatch.setattr(backtest_engine, "_find_pivot_highs_levels", spy("highs_levels", real_highs_levels))
monkeypatch.setattr(backtest_engine, "_find_pivot_lows_levels", spy("lows_levels", real_lows_levels))
monkeypatch.setattr(backtest_engine, "_find_pivot_highs", spy("highs", real_highs))
monkeypatch.setattr(backtest_engine, "_find_pivot_lows", spy("lows", real_lows))
base = datetime.now(timezone.utc) - timedelta(hours=500)
prices = [100 + 10 * math.sin(i / 7) for i in range(500)]
candles = _build_candle_series(base, prices)
precomputed = backtest_engine._precompute_indicators(candles, "1h")
scores = backtest_engine._compute_scores_series(candles, precomputed)
assert len(scores) > 400 # sanity: the loop actually ran over most of the 500 candles
# Called once for the main timeframe plus once per configured MTF
# sub-timeframe (a small constant, e.g. 2-3) — never once per candle
# (which would show call counts in the hundreds here).
for name, count in call_counts.items():
assert 1 <= count <= 4, f"{name} called {count} times — expected a small constant, not scaling with candle count"
def test_order_block_detection_uses_bounded_window_regardless_of_dataset_size(monkeypatch):
"""The other potentially-expensive-per-candle SMC piece: order block
detection must always be called with a small, bounded candle window
(~_ORDER_BLOCK_LOOKBACK), never one that grows with the candle index."""
seen_lengths: list[int] = []
real_detect_order_blocks = backtest_engine._detect_order_blocks
def spy(candles, *args, **kwargs):
seen_lengths.append(len(candles))
return real_detect_order_blocks(candles, *args, **kwargs)
monkeypatch.setattr(backtest_engine, "_detect_order_blocks", spy)
base = datetime.now(timezone.utc) - timedelta(hours=500)
prices = [100 + 10 * math.sin(i / 7) for i in range(500)]
candles = _build_candle_series(base, prices)
precomputed = backtest_engine._precompute_indicators(candles, "1h")
backtest_engine._compute_scores_series(candles, precomputed)
assert len(seen_lengths) > 0
assert max(seen_lengths) <= backtest_engine._ORDER_BLOCK_LOOKBACK + 3
+4 -4
View File
@@ -89,7 +89,7 @@ async def test_grid_search_picks_the_best_scoring_combo(monkeypatch):
best one and reports its stats."""
good_params = {"strong_threshold": 4.5, "signal_threshold": 1.5, "max_hold_candles": 96}
def fake_run_combo(candles, precomputed, trade_size, params, active_from_index):
def fake_run_combo(candles, scores_series, trade_size, params):
if params == good_params:
trades = [{"pnl": 10.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 110.0} for _ in range(10)]
else:
@@ -103,7 +103,7 @@ async def test_grid_search_picks_the_best_scoring_combo(monkeypatch):
grid = {"strong_threshold": [3.5, 4.5], "signal_threshold": [1.0, 1.5], "max_hold_candles": [48, 96]}
best_params, best_score, best_stats = walk_forward._grid_search(
candles=[], precomputed={}, param_grid=grid, trade_size=Decimal("10"), active_from_index=0,
candles=[], scores_series=[], param_grid=grid, trade_size=Decimal("10"),
)
assert best_params == good_params
@@ -113,7 +113,7 @@ async def test_grid_search_picks_the_best_scoring_combo(monkeypatch):
@pytest.mark.asyncio
async def test_grid_search_falls_back_when_every_combo_too_sparse(monkeypatch):
def fake_run_combo(candles, precomputed, trade_size, params, active_from_index):
def fake_run_combo(candles, scores_series, trade_size, params):
# 1 trade, below MIN_TRADES_PER_FOLD
return [], [{"pnl": 1.0, "status": "CLOSED", "entry_price": 100.0, "exit_price": 101.0}]
@@ -121,7 +121,7 @@ async def test_grid_search_falls_back_when_every_combo_too_sparse(monkeypatch):
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=[], precomputed={}, param_grid=grid, trade_size=Decimal("10"), active_from_index=0,
candles=[], scores_series=[], param_grid=grid, trade_size=Decimal("10"),
)
# Falls back to the first grid combination rather than raising
@@ -120,10 +120,13 @@ const SIGNAL_ICONS: Record<string, string> = {
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
const TIMEFRAMES = ALL_TIMEFRAMES;
// Walk-forward is capped at 1h/4h/1d — over the ~3yr default lookback, 1w
// gives only ~150 candles and 1M only ~36, too few to split into folds
// with enough history left for indicator warmup (BB/RSI need 20+ candles).
const WF_TIMEFRAMES = ['1h', '4h', '1d'];
// 1w/1M excluded — over the ~3yr default lookback, 1w gives only ~150
// candles and 1M only ~36, too few to split into folds with enough
// history left for indicator warmup (BB/RSI need 20+ candles). 15m/30m/1h/
// 4h/1d are all fast now that SMC/divergence scoring is O(1)-amortized
// per candle instead of rescanning pivots on every one (see
// backtest_engine.py's _compute_scores_series).
const WF_TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d'];
const selectClass = 'rounded-md border border-border-default bg-bg-surface px-2.5 py-1.5 text-sm text-text-primary';
const thClass = 'px-3 py-2 text-left font-medium text-text-secondary';
+16 -3
View File
@@ -62,8 +62,19 @@ Sau khi review, người dùng lưu ý 2 điểm:
### 2.7 Giới hạn đã biết (cố ý, có ghi chú trong code)
- Chỉ tối ưu 3 tham số ngưỡng/thời gian giữ lệnh, không tối ưu các hằng số nội bộ của 13 thuật toán — xem mục 2.2.
- Timeframe walk-forward giới hạn 1h/4h/1d trên UI (không cho 15m/30m/1w/1M) — 15m/30m để giữ runtime nhanh (thuật toán backtest gốc dùng slicing mảng O(n²) mỗi candle, ở 4h/1h/1d trên 3 năm vẫn đủ nhanh cho 1 request đồng bộ, nhưng ở 15m/30m số nến tăng gấp 4-16 lần sẽ chậm đáng kể); 1w/1M vì lookback 3 năm chỉ cho ~150/~36 nến, không đủ chia fold có ý nghĩa (xem mục 2.6). Không sửa thuật toán slicing gốc (out of scope, rủi ro cao hơn lợi ích ở đây).
- Chưa build async job/polling — `/walk-forward/run` chạy đồng bộ. Với default 3 năm/4h/9 fold thì đủ nhanh; nếu người dùng chỉnh nâng cao để chạy timeframe 1h với total_days rất lớn có thể chậm hơn — chưa có giới hạn cứng, chỉ giới hạn `total_days` trong khoảng [180, 1825] qua validation.
- Chưa build async job/polling — `/walk-forward/run` chạy đồng bộ. Sau khi tối ưu ở mục 2.8, cả 5 timeframe (15m/30m/1h/4h/1d) đều đủ nhanh cho 1 request đồng bộ ở lookback mặc định 3 năm; `total_days` vẫn giới hạn [180, 1825] qua validation để tránh lạm dụng.
### 2.8 Đào sâu thêm: sửa tận gốc thuật toán O(n²) + phát hiện thêm 1 look-ahead bug ở Divergence
Sau khi báo cáo mục 2.6 rằng Walk-Forward chỉ mở được 1h/4h/1d (không có 15m/30m vì quá chậm), người dùng yêu cầu sửa tận gốc thay vì chấp nhận giới hạn. Quá trình đào sâu phát hiện thêm và xử lý:
1. **Phát hiện thêm:** `detect_divergence()` (phát hiện phân kỳ RSI/MACD) có **cùng loại look-ahead bug** như SMC ở mục 2.6 — cũng được tính 1 lần trên toàn bộ dữ liệu rồi dùng lại cho mọi nến, khiến divergence "nhìn thấy" giá tương lai.
2. **Phát hiện thêm:** Walk-forward grid search (27 tổ hợp tham số) **chưa thực sự tái sử dụng** điểm số đã tính — dù đã tách `_compute_adjusted_score`/`_score_to_signal` ở mục "Tối ưu hiệu năng quan trọng" (2.4), `_simulate_trades` vẫn gọi lại toàn bộ 13 thuật toán cho mỗi tổ hợp tham số thay vì tính 1 lần rồi replay ngưỡng.
3. **Fix tận gốc cho O(n²) + look-ahead của SMC/Divergence:** Nhận ra việc dò điểm pivot (`_find_pivot_highs/_lows[_levels]`) tự nó đã là một phép quét cửa sổ trượt có giới hạn (mỗi vị trí chỉ phụ thuộc `pivot_lookback` nến hai bên, không phụ thuộc toàn bộ tương lai) — nên tính được **1 lần duy nhất cho toàn bộ dữ liệu**, y hệt cách làm với BB/RSI/MACD. Mỗi nến sau đó chỉ cần biết pivot nào đã "được xác nhận" tính đến thời điểm đó (một điểm pivot tại vị trí p chỉ được xác nhận là pivot thật sau `pivot_lookback` nến kế tiếp — độ trễ nhỏ, cố định, đúng bản chất của chỉ báo pivot, giống hệt cách nó hoạt động khi chạy live, không phải look-ahead) — theo dõi bằng 1 con trỏ chỉ tăng dần + danh sách "đã xác nhận" chỉ được thêm vào, không bao giờ dựng lại từ đầu → tổng chi phí cho toàn bộ lượt chạy là O(n), không phải O(n) mỗi nến.
4. **Đo thực tế xác nhận hiệu quả:** Sau bước 3 ở trên (đã sửa từ vòng lặp O(n²) xuống O(n) ở lần sửa trước), thời gian tiếp tục giảm thêm **~9.5 lần** (16.000 nến: 16.1s → 1.7s; 32.000 nến chỉ 3.2s, vẫn tuyến tính). Ước tính walk-forward 15m ở lookback 3 năm mặc định giờ chỉ còn **~30 giây** (trước đó ước tính hơn 5 phút).
5. Đã thêm `precomputed_pivots` (tham số optional) vào `detect_divergence()` trong `indicator_service.py` — tương thích ngược 100%, mọi nơi khác gọi hàm này (kể cả live trading) không đổi hành vi.
6. **Mở đủ 5 timeframe cho Walk-Forward:** `15m/30m/1h/4h/1d` (trước đó chỉ `1h/4h/1d`). Vẫn không có 1w/1M — lý do không đổi (không đủ nến trong lookback 3 năm để chia fold có ý nghĩa, xem mục 2.6).
7. Thêm 3 test mới xác nhận: (a) điểm số 1 nến không đổi dù tương lai của nó "êm" hay "sập mạnh" (chứng minh hết look-ahead), (b) hàm dò pivot chỉ chạy đúng 1 lần bất kể dữ liệu dài bao nhiêu, (c) order-block detection luôn nhận cửa sổ nến giới hạn nhỏ, không phình theo tổng số nến. Tổng **156 test pass**.
---
@@ -96,6 +107,8 @@ Sau khi review, người dùng lưu ý 2 điểm:
| w | Link `<a href>` thường gây full reload ở AdminPage | ⏳ Ghi nhận, không ưu tiên |
| x | refreshAccessToken() ép logout khi backend lỗi tạm thời | ⏳ Ghi nhận, cần bàn thiết kế riêng |
| y | (mới) Alembic có 2 head phân nhánh không hợp nhất | ✅ Đã merge (mục 2.5) |
| z | (mới) `detect_divergence()` có look-ahead bug giống SMC | ✅ Đã sửa (mục 2.8) |
| aa | (mới) Walk-forward grid search chưa tái sử dụng điểm số đã tính | ✅ Đã sửa (mục 2.8) |
**Toàn bộ danh sách gốc (a→r) từ bản đánh giá v0 nay đã được xử lý.** Việc còn lại ngoài danh sách gốc: bật 2FA cho tài khoản Gitea (cố ý hoãn vô thời hạn theo yêu cầu "chưa cần thiết cho hiện tại"), và 2 phát hiện phụ (w, x) không ưu tiên.
@@ -124,4 +137,4 @@ Sau khi review, người dùng lưu ý 2 điểm:
| v4 | 2026-07-03 | Rewrite lịch sử git (a) chuẩn bị xong, fix (p), test indicator_service + async signal_service (122 test), phát hiện (q)/(r), ghi nhận sự cố quy trình (s) |
| v5 | 2026-07-03 | Đóng hoàn toàn sự cố (a) — rotate xong 4/4 mật khẩu, force-push lịch sử đã rewrite thành công, verify sạch |
| v6 | 2026-07-04 | Xử lý (h), (i), (j), (k), (l), (n), (o), (q), (r) — 134 test pass; Tailwind design system 14 file; thêm Redis; review UI, phát hiện & sửa (t, u, v), ghi nhận (w, x) |
| v7 | 2026-07-04 | **(m) Walk-Forward Backtest Optimization** — grid search tự động 3 tham số, out-of-sample stitching, UI tab mới trong `/backtest`; tách `backtest_engine.py` khỏi API layer (tiếp nối tinh thần (h)); phát hiện & sửa Alembic 2-head phân nhánh (y); 153 test pass. **Toàn bộ danh sách nhược điểm gốc a→r đã đóng.** Bổ sung sau phản hồi người dùng: đồng bộ `ALL_TIMEFRAMES` (15m/30m/1h/4h/1d/1w/1M) làm nguồn duy nhất thay vì 4 bản sao lệch nhau, mở Walk-Forward thêm 1d, sửa bug `tf_minutes` thiếu "1d" ở backend; thêm `formatVN*` (GMT+7 tường minh) thay toàn bộ chỗ hiển thị giờ UTC/local-browser sai trước đó (10 vị trí). |
| v7 | 2026-07-04 | **(m) Walk-Forward Backtest Optimization** — grid search tự động 3 tham số, out-of-sample stitching, UI tab mới trong `/backtest`; tách `backtest_engine.py` khỏi API layer (tiếp nối tinh thần (h)); phát hiện & sửa Alembic 2-head phân nhánh (y); 153 test pass. **Toàn bộ danh sách nhược điểm gốc a→r đã đóng.** Bổ sung 1: đồng bộ `ALL_TIMEFRAMES` làm nguồn duy nhất, sửa bug `tf_minutes` thiếu "1d"; thêm `formatVN*` (GMT+7 tường minh) thay toàn bộ chỗ hiển thị giờ sai trước đó (10 vị trí). Bổ sung 2 (mục 2.8): sửa tận gốc thuật toán O(n²) của SMC/Divergence bằng cách precompute pivot 1 lần + theo dõi bằng con trỏ chạy tăng dần (O(n) tổng, không phải O(n) mỗi nến) — giảm thêm ~9.5 lần thời gian chạy; phát hiện & sửa thêm look-ahead bug ở `detect_divergence()` (z) và sửa walk-forward grid search chưa tái sử dụng điểm số đã tính (aa); mở đủ 15m/30m/1h/4h/1d cho Walk-Forward. **156 test pass.** |