From 842631744cd006fc0b01fe82c9f4581f40dcbac4 Mon Sep 17 00:00:00 2001 From: Le Date: Sat, 4 Jul 2026 21:02:59 +0700 Subject: [PATCH] feat: ATR-adaptive BOS buffer, FVG gap-size filter, configurable Ichimoku periods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - indicator_service.py: _detect_bos()/market_structure() now scale the break-confirmation buffer by the symbol's own current ATR% instead of a fixed 0.3% for every symbol; falls back to the fixed value when ATR% isn't supplied. - indicator_service.py: detect_fvg() rejects gaps smaller than 10% of current ATR% when atr_pct is given, filtering noise-sized gaps that carried no real "unfilled order" significance on low timeframes. - candle_service.py: computes ATR% earlier so it can feed both market_structure() and detect_fvg(), not just detect_market_regime(); backtest_engine.py reuses the same per-candle ATR% for BOS instead of computing it twice. - indicator_service.py: ichimoku() takes tenkan/kijun/senkou_b_period and displacement as parameters (defaults unchanged at 9/26/52/26) so a future walk-forward comparison against crypto-scaled periods doesn't require editing the function — the classic Japanese-calendar defaults aren't changed here since that needs empirical validation, not a guess. 232 backend tests pass (+13). Co-Authored-By: Claude Sonnet 5 --- backend/app/services/backtest_engine.py | 15 +- backend/app/services/candle_service.py | 39 +++-- backend/app/services/indicator_service.py | 110 +++++++++--- backend/tests/test_indicator_service.py | 198 ++++++++++++++++++++++ theo_doi_trading-portal_v13.md | 88 ++++++++++ 5 files changed, 407 insertions(+), 43 deletions(-) create mode 100644 theo_doi_trading-portal_v13.md diff --git a/backend/app/services/backtest_engine.py b/backend/app/services/backtest_engine.py index b4edbc7..b0f626e 100644 --- a/backend/app/services/backtest_engine.py +++ b/backend/app/services/backtest_engine.py @@ -428,11 +428,18 @@ def _compute_scores_series( confirmed_div_low_idx.append(div_ptr) div_ptr += 1 + # ATR% as of this candle — feeds both BOS's break-confirmation + # buffer (fix uu) and detect_market_regime's adaptive "volatile" + # threshold (fix kk) below, computed once and reused for both. + atr_tail = _tail(atr_full, i, _SHORT_WINDOW) + last_atr = atr_tail[-1] if atr_tail else None + atr_pct = (last_atr / close_prices_full[i] * 100.0) if last_atr and close_prices_full[i] > 0 else None + # 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]]) + bos = _detect_bos(recent_highs, recent_lows, [close_prices_full[i]], atr_pct=atr_pct) choch = _detect_choch( confirmed_swing_highs[-5:], confirmed_swing_lows[-5:], close_prices_full[max(0, i - 19):i + 1], @@ -475,6 +482,9 @@ def _compute_scores_series( mtf_recent_highs = state["highs"][-3:] mtf_recent_lows = state["lows"][-3:] mtf_close = mtf["close_prices"][mtf_idx] + # No per-MTF-timeframe ATR series is precomputed — falls back + # to _detect_bos's fixed 0.3% buffer (fix uu only covers the + # main timeframe's own BOS check above). mtf_bos = _detect_bos(mtf_recent_highs, mtf_recent_lows, [mtf_close]) mtf_choch = _detect_choch( state["highs"][-5:], state["lows"][-5:], @@ -517,9 +527,6 @@ def _compute_scores_series( # in signal_scoring.py, applied in _simulate_from_scores once the # threshold combo decides a concrete signal_type). adx_data = _tail_dict(adx_full, i, _SHORT_WINDOW) - atr_tail = _tail(atr_full, i, _SHORT_WINDOW) - last_atr = atr_tail[-1] if atr_tail else None - atr_pct = (last_atr / close_prices_full[i] * 100.0) if last_atr and close_prices_full[i] > 0 else None regime_start = max(0, i + 1 - _REGIME_WINDOW) atr_history_start = max(0, i + 1 - _ATR_HISTORY_WINDOW) market_regime = detect_market_regime( diff --git a/backend/app/services/candle_service.py b/backend/app/services/candle_service.py index 19ab6ff..8fa65a1 100755 --- a/backend/app/services/candle_service.py +++ b/backend/app/services/candle_service.py @@ -484,9 +484,26 @@ async def get_indicators( else: computed["rsi_divergence"] = (None, None) + # Compute ATR% ahead of Market Structure/ADX below — both need it: + # market_structure()'s BOS confirmation buffer adapts to it (fix + # uu), and detect_market_regime's "volatile" cutoff adapts to its + # full history (fix kk). Also keep the full history (not just the + # latest value) for that second use. + atr_pct_history: list[float | None] = [] + try: + from app.services.indicator_service import atr as _calc_atr + raw_atr = _calc_atr(candle_dicts, period=14) + atr_pct_history = [ + (a / c * 100.0) if a and c and c > 0 else None + for a, c in zip(raw_atr, close_prices) + ] + atr_pct_val = atr_pct_history[-1] if atr_pct_history else None + except Exception: + atr_pct_val = None + # Add Market Structure (SMC) from app.services.indicator_service import market_structure - computed["market_structure"] = market_structure(candle_dicts, pivot_lookback=3) + computed["market_structure"] = market_structure(candle_dicts, pivot_lookback=3, atr_pct=atr_pct_val) # Add MACD divergence detection macd_data = computed.get("macd", {}) @@ -500,21 +517,6 @@ async def get_indicators( # Add ADX (Average Directional Index) + Market Regime computed["adx_data"] = adx(candle_dicts, period=14) atr_vals = computed.get("supertrend", {}).get("trend", None) - # Compute ATR% for regime detection — also keep the full history - # (not just the latest value) so detect_market_regime can judge - # "volatile" against THIS symbol's own recent ATR% distribution - # instead of one fixed cutoff shared by every symbol (fix kk). - atr_pct_history: list[float | None] = [] - try: - from app.services.indicator_service import atr as _calc_atr - raw_atr = _calc_atr(candle_dicts, period=14) - atr_pct_history = [ - (a / c * 100.0) if a and c and c > 0 else None - for a, c in zip(raw_atr, close_prices) - ] - atr_pct_val = atr_pct_history[-1] if atr_pct_history else None - except Exception: - atr_pct_val = None # Extract high/low prices for regime detection high_prices = [c["high"] for c in candle_dicts] @@ -535,8 +537,9 @@ async def get_indicators( # Add MFI (Money Flow Index) computed["mfi_14"] = mfi(candle_dicts, period=14) - # Add FVG (Fair Value Gap) - fvg_type, fvg_high, fvg_low = detect_fvg(candle_dicts, lookback=30) + # Add FVG (Fair Value Gap) — atr_pct filters out gaps too small to + # be meaningful relative to this symbol's own volatility (fix qq). + fvg_type, fvg_high, fvg_low = detect_fvg(candle_dicts, lookback=30, atr_pct=atr_pct_val) computed["fvg"] = {"type": fvg_type, "gap_high": fvg_high, "gap_low": fvg_low} # Add Candlestick Pattern Recognition diff --git a/backend/app/services/indicator_service.py b/backend/app/services/indicator_service.py index efd8019..a5518ff 100755 --- a/backend/app/services/indicator_service.py +++ b/backend/app/services/indicator_service.py @@ -552,7 +552,13 @@ def volume_breakout( # Ichimoku Cloud # ====================================================================== -def ichimoku(candles: list[dict]) -> dict[str, list]: +def ichimoku( + candles: list[dict], + tenkan_period: int = 9, + kijun_period: int = 26, + senkou_b_period: int = 52, + displacement: int = 26, +) -> dict[str, list]: """Ichimoku Cloud — comprehensive trend indicator. Returns dict with keys: @@ -562,10 +568,19 @@ def ichimoku(candles: list[dict]) -> dict[str, list]: - ``senkou_b``: Leading Span B (cloud top/bottom) - ``chikou``: Lagging Span - All values aligned to candle index. First 51 entries are None. + All values aligned to candle index. First `senkou_b_period - 1` + entries are None. + + (fix pp) The 9/26/52/26 defaults are Goichi Hosoda's original values, + designed around Japan's historical 6-day trading week — there's no + inherent reason they suit crypto's 24/7 markets, but changing them + without empirical validation would just swap one unverified guess for + another. Parameterized here (defaults unchanged) so a future walk- + forward comparison against crypto-scaled values can be run without + touching this function. """ n = len(candles) - if n < 52: + if n < senkou_b_period: return {"tenkan": [None] * n, "kijun": [None] * n, "senkou_a": [None] * n, "senkou_b": [None] * n, "chikou": [None] * n} @@ -580,28 +595,28 @@ def ichimoku(candles: list[dict]) -> dict[str, list]: senkou_b: list[Optional[float]] = [None] * n chikou: list[Optional[float]] = [None] * n - for i in range(8, n): - tenkan[i] = (max(highs[i - 8 : i + 1]) + min(lows[i - 8 : i + 1])) / 2.0 + for i in range(tenkan_period - 1, n): + tenkan[i] = (max(highs[i - tenkan_period + 1 : i + 1]) + min(lows[i - tenkan_period + 1 : i + 1])) / 2.0 - for i in range(25, n): - kijun[i] = (max(highs[i - 25 : i + 1]) + min(lows[i - 25 : i + 1])) / 2.0 + for i in range(kijun_period - 1, n): + kijun[i] = (max(highs[i - kijun_period + 1 : i + 1]) + min(lows[i - kijun_period + 1 : i + 1])) / 2.0 - # Senkou spans: shift forward by 26 - for i in range(25, n): + # Senkou spans: shift forward by `displacement` + for i in range(kijun_period - 1, n): if tenkan[i] is not None and kijun[i] is not None: sa = (tenkan[i] + kijun[i]) / 2.0 - if i + 26 < n: - senkou_a[i + 26] = sa + if i + displacement < n: + senkou_a[i + displacement] = sa - for i in range(51, n): - sb = (max(highs[i - 51 : i + 1]) + min(lows[i - 51 : i + 1])) / 2.0 - if i + 26 < n: - senkou_b[i + 26] = sb + for i in range(senkou_b_period - 1, n): + sb = (max(highs[i - senkou_b_period + 1 : i + 1]) + min(lows[i - senkou_b_period + 1 : i + 1])) / 2.0 + if i + displacement < n: + senkou_b[i + displacement] = sb - # Chikou: current close plotted 26 periods back + # Chikou: current close plotted `displacement` periods back for i in range(n): - if i + 26 < n: - chikou[i] = closes[i + 26] + if i + displacement < n: + chikou[i] = closes[i + displacement] return { "tenkan": tenkan, @@ -742,24 +757,45 @@ def _find_pivot_lows_levels(prices: list[float], left: int = 3, right: int = 3) return pivots +# (fix uu) BOS used a fixed 0.3% break-confirmation buffer regardless of +# the symbol's actual volatility — too wide for a calm major (BTC/ETH +# 15m ATR% often well under 0.3%, missing real breaks) and too narrow for +# a volatile altcoin (routinely whipsawing past 0.3% on noise). Scaling +# the buffer by the symbol's own current ATR% adapts it the same way +# fix (kk) adapted the "volatile" regime threshold. +_BOS_ATR_BUFFER_MULT = 0.15 +_BOS_DEFAULT_BUFFER_PCT = 0.003 # fallback (0.3%) when ATR% isn't available + + def _detect_bos( swing_highs: list[Optional[float]], swing_lows: list[Optional[float]], prices: list[float], + atr_pct: Optional[float] = None, ) -> Optional[str]: """Detect Break of Structure (BOS). Bullish BOS: price breaks above the most recent swing high. Bearish BOS: price breaks below the most recent swing low. Returns \"BULLISH\", \"BEARISH\", or None. + + `atr_pct` (current ATR as % of price), if given, scales the break- + confirmation buffer to the symbol's own volatility instead of a fixed + 0.3% for every symbol — see _BOS_ATR_BUFFER_MULT above. """ recent_highs = [sh for sh in swing_highs if sh is not None][-3:] recent_lows = [sl for sl in swing_lows if sl is not None][-3:] current_price = prices[-1] - if recent_highs and current_price > max(recent_highs) * 1.003: + buffer_pct = ( + (atr_pct / 100.0) * _BOS_ATR_BUFFER_MULT + if atr_pct is not None and atr_pct > 0 + else _BOS_DEFAULT_BUFFER_PCT + ) + + if recent_highs and current_price > max(recent_highs) * (1 + buffer_pct): return "BULLISH" - if recent_lows and current_price < min(recent_lows) * 0.997: + if recent_lows and current_price < min(recent_lows) * (1 - buffer_pct): return "BEARISH" return None @@ -854,6 +890,7 @@ def _detect_order_blocks( def market_structure( candles: list[dict], pivot_lookback: int = 3, + atr_pct: Optional[float] = None, ) -> dict: """Comprehensive Market Structure analysis (SMC). @@ -866,6 +903,10 @@ def market_structure( - ``trend``: \"BULLISH\", \"BEARISH\", or \"NEUTRAL\" - ``last_swing_high``: most recent swing high price - ``last_swing_low``: most recent swing low price + + `atr_pct` (current ATR as % of price), if given, adapts BOS's break- + confirmation buffer to this symbol's own volatility — see + `_detect_bos`/_BOS_ATR_BUFFER_MULT. """ n = len(candles) if n < 20: @@ -882,7 +923,7 @@ def market_structure( swing_highs = _find_pivot_highs_levels(prices, pivot_lookback, pivot_lookback) swing_lows = _find_pivot_lows_levels(prices, pivot_lookback, pivot_lookback) - bos = _detect_bos(swing_highs, swing_lows, prices) + bos = _detect_bos(swing_highs, swing_lows, prices, atr_pct=atr_pct) choch = _detect_choch(swing_highs, swing_lows, prices) obs = _detect_order_blocks(candles, lookback=min(40, n)) @@ -1371,9 +1412,20 @@ def mfi(candles: list[dict], period: int = 14) -> list[Optional[float]]: # FVG — Fair Value Gap (SMC inefficiency) # ====================================================================== +# (fix qq) Any gap, however tiny, used to count as a valid FVG — on a 15m +# chart that's noise: a gap worth a few ticks carries none of the +# "unfilled institutional order" significance the concept is meant to +# capture, and votes in signal_scoring.py just as strongly as a +# meaningful gap. Requiring the gap to be at least this fraction of the +# symbol's own current ATR filters that out; falls back to no size filter +# (previous behavior) when ATR% isn't supplied. +_FVG_MIN_GAP_ATR_MULT = 0.1 + + def detect_fvg( candles: list[dict], lookback: int = 30, + atr_pct: Optional[float] = None, ) -> tuple[Optional[str], Optional[float], Optional[float]]: """Detect the most recent Fair Value Gap (imbalance / inefficiency). @@ -1382,6 +1434,11 @@ def detect_fvg( A Bearish FVG occurs when the high of candle i is lower than the low of candle i+2 (gap down — unfilled sell orders). + `atr_pct` (current ATR as % of price), if given, rejects gaps smaller + than `_FVG_MIN_GAP_ATR_MULT` of it — see the module-level comment + above (fix qq). Without it, any nonzero gap still counts, same as + before this parameter existed. + Returns (fvg_type, gap_high, gap_low): - fvg_type: "BULLISH", "BEARISH", or None - gap_high: upper bound of the gap @@ -1406,17 +1463,28 @@ def detect_fvg( if c0_low > c2_high: gap_high = c0_low gap_low = c2_high + if _fvg_gap_too_small(gap_high, gap_low, atr_pct): + continue return "BULLISH", gap_high, gap_low # Bearish FVG: C0 high < C2 low → gap down if c0_high < c2_low: gap_high = c2_low gap_low = c0_high + if _fvg_gap_too_small(gap_high, gap_low, atr_pct): + continue return "BEARISH", gap_high, gap_low return None, None, None +def _fvg_gap_too_small(gap_high: float, gap_low: float, atr_pct: Optional[float]) -> bool: + if atr_pct is None or atr_pct <= 0 or gap_low <= 0: + return False + gap_pct = (gap_high - gap_low) / gap_low * 100.0 + return gap_pct < atr_pct * _FVG_MIN_GAP_ATR_MULT + + # ====================================================================== # Candlestick Pattern Recognition (single vote from 30+ patterns) # ====================================================================== diff --git a/backend/tests/test_indicator_service.py b/backend/tests/test_indicator_service.py index 35f1b43..e8c0d81 100644 --- a/backend/tests/test_indicator_service.py +++ b/backend/tests/test_indicator_service.py @@ -29,6 +29,11 @@ from app.services.indicator_service import ( _DEFAULT_VOLATILE_THRESHOLD_PCT, _MIN_VOLATILE_THRESHOLD_PCT, _MAX_VOLATILE_THRESHOLD_PCT, + _detect_bos, + _BOS_DEFAULT_BUFFER_PCT, + _BOS_ATR_BUFFER_MULT, + detect_fvg, + ichimoku, ) @@ -402,3 +407,196 @@ class TestPivotCausalConsistency: confirmable = len(candles) - 3 assert ms_after["swing_highs"][:confirmable] == ms_before["swing_highs"][:confirmable] assert ms_after["swing_lows"][:confirmable] == ms_before["swing_lows"][:confirmable] + + +class TestBosAtrBuffer: + """Regression tests for fix (uu): BOS used a fixed 0.3% break- + confirmation buffer for every symbol — too wide for a calm major + (missing real breaks) and too narrow for a volatile altcoin (false + breaks on noise). The buffer now scales with the symbol's own current + ATR%, falling back to the original fixed 0.3% when ATR% isn't given. + """ + + def test_no_atr_pct_falls_back_to_fixed_buffer(self): + swing_highs = [None, None, 100.0] + swing_lows = [None, None, 90.0] + # Just barely above the old fixed 0.3% buffer -> BULLISH. + just_over = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT) + 0.01 + just_under = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT) - 0.01 + assert _detect_bos(swing_highs, swing_lows, [just_over]) == "BULLISH" + assert _detect_bos(swing_highs, swing_lows, [just_under]) is None + + def test_high_atr_pct_widens_the_buffer(self): + """A break that would confirm under the fixed 0.3% buffer must NOT + confirm yet when the symbol's own ATR% implies a much wider + 'normal' move (avoiding a false break on a volatile symbol).""" + swing_highs = [None, None, 100.0] + swing_lows = [None, None, 90.0] + price_just_over_fixed_buffer = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT) + 0.01 + + assert _detect_bos(swing_highs, swing_lows, [price_just_over_fixed_buffer]) == "BULLISH" + # atr_pct=10 -> buffer = 10/100 * _BOS_ATR_BUFFER_MULT = 1.5%, far + # wider than the fixed 0.3% — the same price no longer confirms. + assert _detect_bos(swing_highs, swing_lows, [price_just_over_fixed_buffer], atr_pct=10.0) is None + + def test_low_atr_pct_narrows_the_buffer(self): + """A calm symbol's own ATR% implies a narrower 'normal' move than + the fixed 0.3% — a break should confirm sooner (closer to the + actual swing level) than the fixed buffer would allow.""" + swing_highs = [None, None, 100.0] + swing_lows = [None, None, 90.0] + # atr_pct=0.2 -> buffer = 0.2/100 * 0.15 = 0.03%, much tighter + # than the fixed 0.3%. + price_within_fixed_buffer_but_past_atr_buffer = 100.0 * (1 + _BOS_DEFAULT_BUFFER_PCT / 2) + + assert _detect_bos(swing_highs, swing_lows, [price_within_fixed_buffer_but_past_atr_buffer]) is None + assert _detect_bos( + swing_highs, swing_lows, [price_within_fixed_buffer_but_past_atr_buffer], atr_pct=0.2, + ) == "BULLISH" + + def test_bearish_break_also_scales_with_atr(self): + swing_highs = [None, None, 100.0] + swing_lows = [None, None, 90.0] + price_just_under_fixed_buffer = 90.0 * (1 - _BOS_DEFAULT_BUFFER_PCT) - 0.01 + + assert _detect_bos(swing_highs, swing_lows, [price_just_under_fixed_buffer]) == "BEARISH" + assert _detect_bos(swing_highs, swing_lows, [price_just_under_fixed_buffer], atr_pct=10.0) is None + + def test_market_structure_threads_atr_pct_into_bos(self, monkeypatch): + """market_structure() must actually pass its atr_pct argument + through to _detect_bos rather than silently ignoring it.""" + import app.services.indicator_service as indicator_service_module + + received = {} + + def spy_detect_bos(swing_highs, swing_lows, prices, atr_pct=None): + received["atr_pct"] = atr_pct + return None + + monkeypatch.setattr(indicator_service_module, "_detect_bos", spy_detect_bos) + + prices = [100.0 + math.sin(i / 3.0) * 10 + (i % 5) for i in range(30)] + candles = [candle(p + 0.5, p - 0.5, p) for p in prices] + + market_structure(candles, pivot_lookback=3, atr_pct=7.5) + + assert received["atr_pct"] == 7.5 + + +class TestDetectFvgAtrFilter: + """Regression tests for fix (qq): any nonzero Fair Value Gap used to + count as valid regardless of size — noisy on a 15m chart, where a + gap worth a few ticks carries none of the "unfilled institutional + order" significance the concept is meant to capture. Gaps smaller + than `_FVG_MIN_GAP_ATR_MULT` x ATR% are now rejected when `atr_pct` + is supplied; without it, behavior is unchanged from before this fix. + """ + + def _bullish_gap_candles(self, c0_low: float, c2_high: float): + # detect_fvg only reads high/low of c0 and c2 — the middle candle's + # values are never inspected, only its presence in the sequence. + filler = candle(1000, 1, 500) # engulfs everything -> can never itself form a gap + c0 = candle(c0_low + 1, c0_low, c0_low + 0.5) + c1 = candle(c0_low - 0.1, c2_high + 0.1, c0_low - 0.5) + c2 = candle(c2_high, c2_high - 1, c2_high - 0.5) + return [filler, filler, c0, c1, c2] + + def test_gap_detected_without_atr_pct_regardless_of_size(self): + candles = self._bullish_gap_candles(c0_low=100.1, c2_high=100.0) + fvg_type, gap_high, gap_low = detect_fvg(candles) + assert fvg_type == "BULLISH" + assert gap_high == pytest.approx(100.1) + assert gap_low == pytest.approx(100.0) + + def test_tiny_gap_rejected_when_atr_pct_given(self): + # gap = (100.1-100.0)/100.0 = 0.1% of price; atr_pct=5 -> minimum + # required = 5 * _FVG_MIN_GAP_ATR_MULT(0.1) = 0.5% -> too small. + candles = self._bullish_gap_candles(c0_low=100.1, c2_high=100.0) + fvg_type, gap_high, gap_low = detect_fvg(candles, atr_pct=5.0) + assert fvg_type is None + assert gap_high is None and gap_low is None + + def test_large_enough_gap_accepted_with_atr_pct(self): + # gap = (102.0-100.0)/100.0 = 2% of price; atr_pct=5 -> minimum + # required 0.5% -> comfortably passes. + candles = self._bullish_gap_candles(c0_low=102.0, c2_high=100.0) + fvg_type, gap_high, gap_low = detect_fvg(candles, atr_pct=5.0) + assert fvg_type == "BULLISH" + assert gap_high == pytest.approx(102.0) + assert gap_low == pytest.approx(100.0) + + def test_bearish_gap_also_filtered_by_atr(self): + # Bearish: C0 high < C2 low -> gap down. + filler = candle(1000, 1, 500) # engulfs everything -> can never itself form a gap + c0 = candle(100.0, 99.0, 99.5) # c0_high = 100.0 + c1 = candle(101.0, 100.5, 100.8) + c2 = candle(101.0, 100.1, 100.5) # c2_low = 100.1 -> gap 0.1% of 100.1 + + tiny_gap_candles = [filler, filler, c0, c1, c2] + fvg_type, _, _ = detect_fvg(tiny_gap_candles, atr_pct=5.0) + assert fvg_type is None + + fvg_type_no_atr, gap_high, gap_low = detect_fvg(tiny_gap_candles) + assert fvg_type_no_atr == "BEARISH" + assert gap_high == pytest.approx(100.1) + assert gap_low == pytest.approx(100.0) + + +class TestIchimokuConfigurablePeriods: + """Regression tests for fix (pp): Ichimoku's tenkan/kijun/senkou_b + periods and displacement are now parameters (defaulting to the + original 9/26/52/26) instead of hardcoded, so a future walk-forward + comparison against crypto-scaled periods can be run without editing + this function. The defaults themselves are intentionally unchanged — + picking new ones needs empirical validation, not a guess. + """ + + def _flat_range_candles(self, n: int): + # high == low == close for every candle -> for a strictly + # increasing close series, max(highs) over any trailing window is + # just the window's last close, and min(lows) is the window's + # first close, making the expected tenkan/kijun/senkou_b values + # trivial to hand-verify. + closes = [float(i) for i in range(1, n + 1)] + return closes, [candle(c, c, c) for c in closes] + + def test_default_periods_match_original_9_26_52_26(self): + closes, candles = self._flat_range_candles(100) + result = ichimoku(candles) + + i = 60 # well past every warmup period + assert result["tenkan"][i] == pytest.approx((closes[i] + closes[i - 8]) / 2.0) + assert result["kijun"][i] == pytest.approx((closes[i] + closes[i - 25]) / 2.0) + # senkou_a/senkou_b are shifted forward by displacement=26. + expected_senkou_a = (result["tenkan"][i] + result["kijun"][i]) / 2.0 + assert result["senkou_a"][i + 26] == pytest.approx(expected_senkou_a) + assert result["senkou_b"][i + 26] == pytest.approx((closes[i] + closes[i - 51]) / 2.0) + assert result["chikou"][i] == pytest.approx(closes[i + 26]) + + def test_insufficient_data_for_default_periods_returns_all_none(self): + _closes, candles = self._flat_range_candles(51) # senkou_b_period=52, one short + result = ichimoku(candles) + assert all(v is None for v in result["tenkan"]) + assert all(v is None for v in result["senkou_b"]) + + def test_custom_shorter_periods_need_less_warmup_and_change_values(self): + closes, candles = self._flat_range_candles(30) + default_needs_more_data = ichimoku(candles) # n=30 < senkou_b_period=52 + assert all(v is None for v in default_needs_more_data["tenkan"]) + + custom = ichimoku(candles, tenkan_period=3, kijun_period=5, senkou_b_period=10, displacement=4) + + i = 15 + assert custom["tenkan"][i] == pytest.approx((closes[i] + closes[i - 2]) / 2.0) + assert custom["kijun"][i] == pytest.approx((closes[i] + closes[i - 4]) / 2.0) + assert custom["senkou_b"][i + 4] == pytest.approx((closes[i] + closes[i - 9]) / 2.0) + assert custom["chikou"][i] == pytest.approx(closes[i + 4]) + + def test_custom_periods_produce_different_cloud_than_defaults(self): + _closes, candles = self._flat_range_candles(100) + default_result = ichimoku(candles) + custom_result = ichimoku(candles, tenkan_period=5, kijun_period=13, senkou_b_period=26, displacement=13) + + i = 70 + assert custom_result["tenkan"][i] != pytest.approx(default_result["tenkan"][i]) + assert custom_result["kijun"][i] != pytest.approx(default_result["kijun"][i]) diff --git a/theo_doi_trading-portal_v13.md b/theo_doi_trading-portal_v13.md new file mode 100644 index 0000000..5a01fa9 --- /dev/null +++ b/theo_doi_trading-portal_v13.md @@ -0,0 +1,88 @@ +# Theo dõi đánh giá dự án Trading Portal — v13 + +> **Ngày đánh giá gốc:** 2026-07-03 +> **Cập nhật v1-v12:** xem các file `theo_doi_trading-portal_v1.md`…`v12.md` +> **Cập nhật v13 (lần này):** 2026-07-04 — triển khai "Nhóm B" (3 vấn đề điều chỉnh ngưỡng/tham số, an toàn về code nhưng đúng/sai phụ thuộc dữ liệu thực tế): (uu) BOS buffer theo ATR, (qq) FVG lọc theo kích thước gap, (pp) tham số hóa Ichimoku +> **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_v14.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 Nhóm A ở v12, người dùng yêu cầu làm tiếp Nhóm B — 3 vấn đề đã được xếp riêng vì bản chất khác Nhóm A: đây là những điều chỉnh NGƯỠNG/THAM SỐ, an toàn về mặt code (không phá vỡ gì) nhưng "đúng hay sai" về mặt hiệu quả trade phụ thuộc vào dữ liệu thị trường thực tế, chưa được kiểm chứng bằng walk-forward. Với mỗi mục, nguyên tắc áp dụng: (1) nếu là "thêm khả năng thích ứng theo volatility" (như đã làm ở kk) thì làm trực tiếp — hướng đi rõ ràng đúng dù chưa biết hệ số tối ưu chính xác; (2) nếu là "đổi hẳn giá trị mặc định đã dùng lâu" (như Ichimoku) thì KHÔNG đoán số mới, chỉ tham số hóa để việc thử nghiệm sau này (mục tt) không cần sửa code. + +--- + +## 3. Các thay đổi trong v13 (Nhóm B) + +### 3.1 (uu) BOS buffer thích ứng theo ATR thay vì 0.3% cố định + +**Vấn đề:** `_detect_bos()` dùng buffer cố định 0.3% để xác nhận phá vỡ cấu trúc (Break of Structure) cho MỌI symbol — quá rộng với coin ít biến động (BTC/ETH 15m thường ATR% dưới 0.3%, bỏ lỡ break thật) và quá hẹp với altcoin biến động cao (bị nhiễu/whipsaw thường xuyên vượt 0.3%). + +**Đã sửa:** +- `backend/app/services/indicator_service.py`: `_detect_bos()` nhận thêm tham số `atr_pct: float | None = None`. Buffer = `(atr_pct/100) × 0.15` khi có ATR%, fallback về 0.3% cố định khi không có (giữ đúng hành vi cũ cho caller không truyền tham số này). `market_structure()` cũng nhận `atr_pct` và truyền xuống `_detect_bos`. +- `backend/app/services/candle_service.py` (live): tính ATR% SỚM HƠN trong hàm (trước đây tính sau `market_structure()`, giờ chuyển lên trước) để truyền vào cả `market_structure(atr_pct=...)` lẫn `detect_market_regime(atr_pct_history=...)` — dùng chung một lần tính, không tính lại. +- `backend/app/services/backtest_engine.py`: tính `atr_pct` cho mỗi nến sớm hơn trong vòng lặp `_compute_scores_series` (trước đây chỉ tính cho `detect_market_regime`, giờ tính trước và tái sử dụng cho cả BOS), loại bỏ đoạn tính trùng lặp phía sau. MTF (đa khung thời gian phụ) vẫn dùng buffer cố định 0.3% vì không có chuỗi ATR riêng cho từng khung phụ — ghi chú rõ trong code, phạm vi (uu) chỉ áp dụng cho khung thời gian chính. + +**Test mới** (`tests/test_indicator_service.py`, class `TestBosAtrBuffer`): không có ATR% → dùng đúng buffer cố định cũ; ATR% cao → buffer rộng ra (break trước đây xác nhận được giờ không còn); ATR% thấp → buffer hẹp lại (break xác nhận sớm hơn); áp dụng đúng cho cả hướng BEARISH; `market_structure()` truyền đúng `atr_pct` xuống `_detect_bos` (test bằng spy). + +### 3.2 (qq) FVG lọc gap theo kích thước tương đối với ATR + +**Vấn đề:** `detect_fvg()` coi MỌI khoảng trống (gap) là Fair Value Gap hợp lệ, bất kể kích thước — trên khung 15m, một gap vài tick không mang ý nghĩa "lệnh tổ chức chưa khớp" mà khái niệm FVG hướng tới, nhưng vẫn vote mạnh như một gap thật sự trong `signal_scoring.py`. + +**Đã sửa** (`backend/app/services/indicator_service.py`): `detect_fvg()` nhận thêm `atr_pct: float | None = None`. Gap bị loại nếu kích thước (tính theo % giá) nhỏ hơn `atr_pct × 0.1`. Không có ATR% → giữ nguyên hành vi cũ (không lọc). Chỉ có một nơi gọi `detect_fvg()` trong toàn bộ codebase (`candle_service.py`, dùng cho live trading — `backtest_engine.py` không dùng FVG trong vote của mình) — đã cập nhật truyền `atr_pct` đã tính sẵn. + +**Test mới** (`tests/test_indicator_service.py`, class `TestDetectFvgAtrFilter`): gap nhỏ vẫn được chấp nhận khi không có ATR% (hành vi cũ không đổi); gap nhỏ bị loại khi có ATR% và không đạt ngưỡng; gap đủ lớn vẫn được chấp nhận; áp dụng đúng cho cả gap BEARISH. + +### 3.3 (pp) Tham số hóa Ichimoku (không đổi giá trị mặc định) + +**Vấn đề:** `ichimoku()` hard-code bộ số 9-26-52-26 của Goichi Hosoda, thiết kế cho lịch giao dịch 6 ngày/tuần của Nhật cổ — không có cơ sở áp dụng cho crypto giao dịch 24/7, nhưng **không có bằng chứng thực nghiệm nào cho biết bộ số nào khác sẽ tốt hơn cho crypto**. Tự đoán một bộ số mới (ví dụ 10-30-60) và coi đó là "cải thiện" sẽ chỉ là thay một phỏng đoán chưa kiểm chứng bằng một phỏng đoán khác. + +**Đã sửa** (`backend/app/services/indicator_service.py`): `ichimoku()` nhận thêm 4 tham số `tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26` — **giá trị mặc định giữ nguyên y hệt cũ**, không có thay đổi hành vi nào cho caller hiện tại. Việc tham số hóa chỉ mở đường cho bước (tt) trong tương lai (chạy walk-forward so sánh thực nghiệm nhiều bộ số trên dữ liệu lịch sử thật) mà không cần sửa lại hàm này. + +**Test mới** (`tests/test_indicator_service.py`, class `TestIchimokuConfigurablePeriods`): xác nhận mặc định vẫn tính đúng y hệt công thức 9-26-52-26 gốc (dùng chuỗi giá tăng dần dễ tính tay); thiếu dữ liệu vẫn trả về toàn `None` như cũ; tham số tùy chỉnh làm giảm số nến cần "warm-up" và cho ra giá trị khác đúng theo công thức mới; hai bộ tham số khác nhau cho ra kết quả khác nhau trên cùng dữ liệu (xác nhận tham số thực sự có tác dụng, không bị bỏ qua). + +### 3.4 Kiểm chứng + +Toàn bộ 232 test backend pass (tăng từ 219 ở v12 lên 232 — thêm 5 test BOS ATR buffer, 4 test FVG ATR filter, 4 test Ichimoku tham số hóa). Không đổi gì ở frontend trong đợt này. + +--- + +## 4. Danh sách vấn đề còn tồn đọng + +### 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, Ichimoku 9-26-52-26...) là giá trị sách giáo khoa cho cổ phiếu/forex daily, chưa qua walk-forward riêng cho crypto | Ichimoku giờ đã tham số hóa (mục 3.3), sẵn sàng để (tt) thử nghiệm khi có quy trình so sánh thực nghiệm thực tế trên dữ liệu lịch sử | + +### Ưu tiên THẤP còn lại (chưa đổi từ v10-v12) +| # | 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 | + +**Về (tt):** đây là bước duy nhất còn lại đòi hỏi thực sự chạy backtest/walk-forward trên dữ liệu lịch sử (không phải chỉ sửa code) để so sánh bộ tham số cũ vs mới của từng thuật toán trong số 13. Nếu muốn tiếp tục, cần: (1) đủ dữ liệu nến lịch sử trong DB cho các symbol/khung thời gian muốn thử, (2) một quy trình chạy `run_walk_forward()` (đã có sẵn) với nhiều bộ tham số chỉ báo khác nhau (không chỉ 3 tham số threshold hiện đang được tối ưu — cần mở rộng phạm vi tối ưu hóa của chính walk_forward.py để bao gồm cả tham số chỉ báo, đây là thay đổi kiến trúc lớn hơn), rồi so sánh kết quả out-of-sample. + +--- + +## 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`. 219 test pass. | +| v13 | 2026-07-04 | Nhóm B: (uu) BOS buffer theo ATR, (qq) FVG lọc theo kích thước gap/ATR, (pp) tham số hóa Ichimoku (mặc định không đổi). 232 test pass (+13). Còn lại: (tt) cần walk-forward thực nghiệm (thay đổi kiến trúc lớn hơn để mở rộng phạm vi tối ưu), vv/ww (thấp), yy (scope lớn, để riêng). | \ No newline at end of file