From 0a93af10a90801e92ced3cf3ebc8db73b3bbc76f Mon Sep 17 00:00:00 2001 From: Le Date: Sat, 4 Jul 2026 21:39:17 +0700 Subject: [PATCH] fix: order block displacement/volume confirmation, Tweezer Bottom epsilon inconsistency - indicator_service.py: _detect_order_blocks() now requires the confirming candles to actually displace price past the OB candle's own high/low (the "imbalance" a real SMC order block is supposed to leave behind) instead of accepting any 2-candle same-direction sequence, even one that stays entirely inside the OB candle's range. strength (1-3) now reflects real displacement/volume confirmation instead of always 1. - indicator_service.py: removed a stray "+ 0.001" from Tweezer Bottom's zero-division guard that Tweezer Top's otherwise-identical formula didn't have -- both already guard via an explicit > 0 check, so the epsilon just made the two directions inconsistent for no reason. 237 backend tests pass (+5). This closes out the full 13-algorithm audit started in v10 -- only (tt) parameter re-optimization (needs empirical walk-forward runs, not a code change) and (yy) multi-position portfolio backtesting (needs a new architecture) remain, both flagged as separate larger investment decisions. Co-Authored-By: Claude Sonnet 5 --- backend/app/services/indicator_service.py | 50 +++++++++++--- backend/tests/test_indicator_service.py | 82 +++++++++++++++++++++++ theo_doi_trading-portal_v14.md | 80 ++++++++++++++++++++++ 3 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 theo_doi_trading-portal_v14.md diff --git a/backend/app/services/indicator_service.py b/backend/app/services/indicator_service.py index a5518ff..63311e7 100755 --- a/backend/app/services/indicator_service.py +++ b/backend/app/services/indicator_service.py @@ -847,40 +847,67 @@ def _detect_order_blocks( Returns list of order blocks, each with: - type: \"BULLISH\" (support) or \"BEARISH\" (resistance) - price_high, price_low - - strength: int (number of consecutive strong moves) + - strength: int (1-3; see below) + + (fix vv) Previously ANY 2-candle same-direction sequence after the OB + candle counted, even if price never actually moved away from that + candle's own range — the defining feature of a real SMC order block + (the "imbalance" it leaves behind) wasn't checked at all, and + `strength` was hardcoded to 1 regardless of conviction. Now requires + the confirming move to displace price past the OB candle's high/low, + and strength reflects (a) that displacement exceeding the OB candle's + own range and (b) above-(local-)average volume on either confirming + candle. Volume only ever *boosts* strength — it's not a hard gate, so + a genuine displacement isn't rejected just because volume data happens + to look flat (e.g. synthetic/backtest data with uniform volume). """ n = len(candles) if n < lookback + 3: return [] + window_start = max(0, n - lookback - 3) + volumes = [float(c.get("volume", 0.0)) for c in candles[window_start:n]] + avg_volume = sum(volumes) / len(volumes) if volumes else 0.0 + blocks: list[dict] = [] for i in range(max(1, n - lookback), n - 2): - prev = candles[i - 1] curr = candles[i] nxt1 = candles[i + 1] nxt2 = candles[i + 2] curr_high = float(curr["high"]) curr_low = float(curr["low"]) + curr_range = curr_high - curr_low nxt1_close = float(nxt1["close"]) nxt2_close = float(nxt2["close"]) + nxt1_vol = float(nxt1.get("volume", 0.0)) + nxt2_vol = float(nxt2.get("volume", 0.0)) + volume_confirmed = avg_volume > 0 and max(nxt1_vol, nxt2_vol) >= avg_volume * 1.2 - # Bullish OB: bearish candle followed by 2 consecutive bullish candles - if float(curr["close"]) < float(curr["open"]) and nxt1_close > float(nxt1["open"]) and nxt2_close > float(nxt2["open"]): + # Bullish OB: bearish candle followed by 2 consecutive bullish + # candles that actually clear above the OB candle's own high. + displacement_up = nxt2_close - curr_high + if (float(curr["close"]) < float(curr["open"]) and nxt1_close > float(nxt1["open"]) + and nxt2_close > float(nxt2["open"]) and curr_range > 0 and displacement_up > 0): + strength = 1 + int(volume_confirmed) + int(displacement_up > curr_range) blocks.append({ "type": "BULLISH", "price_high": curr_high, "price_low": curr_low, - "strength": 1, + "strength": strength, }) - # Bearish OB: bullish candle followed by 2 consecutive bearish candles - if float(curr["close"]) > float(curr["open"]) and nxt1_close < float(nxt1["open"]) and nxt2_close < float(nxt2["open"]): + # Bearish OB: bullish candle followed by 2 consecutive bearish + # candles that actually clear below the OB candle's own low. + displacement_down = curr_low - nxt2_close + if (float(curr["close"]) > float(curr["open"]) and nxt1_close < float(nxt1["open"]) + and nxt2_close < float(nxt2["open"]) and curr_range > 0 and displacement_down > 0): + strength = 1 + int(volume_confirmed) + int(displacement_down > curr_range) blocks.append({ "type": "BEARISH", "price_high": curr_high, "price_low": curr_low, - "strength": 1, + "strength": strength, }) # Keep only the most recent blocks @@ -1571,8 +1598,13 @@ def detect_candlestick_patterns(candles: list[dict]) -> float: if bull1 and bear0 and o0 > h1 and c0c < (o1 + c1c) / 2: _up(-1.5) # ── Tweezer ── + # (fix ww) Tweezer Bottom's denominator had a stray `+ 0.001` not + # present in Tweezer Top's otherwise-identical formula just above — + # both already guard `l1 > 0 and l0 > 0` before dividing, so the + # epsilon was never needed to prevent a zero-division and just + # asymmetrically skewed the bottom's threshold vs. the top's. if h1 > 0 and h0 > 0 and abs(h1 - h0) / max(h1, h0) * 100 < 0.1 and bull1 and bear0: _up(-1.5) - if l1 > 0 and l0 > 0 and abs(l1 - l0) / max(l1 + 0.001, l0) * 100 < 0.1 and bear1 and bull0: _up(1.5) + if l1 > 0 and l0 > 0 and abs(l1 - l0) / max(l1, l0) * 100 < 0.1 and bear1 and bull0: _up(1.5) # ── Morning / Evening Star ── if bear2 and bull0: diff --git a/backend/tests/test_indicator_service.py b/backend/tests/test_indicator_service.py index e8c0d81..3e7e869 100644 --- a/backend/tests/test_indicator_service.py +++ b/backend/tests/test_indicator_service.py @@ -34,6 +34,8 @@ from app.services.indicator_service import ( _BOS_ATR_BUFFER_MULT, detect_fvg, ichimoku, + detect_candlestick_patterns, + _detect_order_blocks, ) @@ -41,6 +43,10 @@ def candle(high, low, close, volume=100.0): return {"high": high, "low": low, "close": close, "volume": volume} +def ohlc(open, high, low, close, volume=100.0): + return {"open": open, "high": high, "low": low, "close": close, "volume": volume} + + class TestSma: def test_leading_entries_are_none(self): result = sma([1, 2, 3, 4, 5], period=3) @@ -600,3 +606,79 @@ class TestIchimokuConfigurablePeriods: i = 70 assert custom_result["tenkan"][i] != pytest.approx(default_result["tenkan"][i]) assert custom_result["kijun"][i] != pytest.approx(default_result["kijun"][i]) + + +class TestCandlestickTweezerSymmetry: + """Regression test for fix (ww): Tweezer Bottom's zero-division guard + had a stray `+ 0.001` epsilon that Tweezer Top's otherwise-identical + formula didn't have — both already guard against a zero denominator + via an explicit `> 0` check, so the epsilon was dead weight that made + the two directions inconsistent for no reason. Locks in that a small + (within-tolerance) gap is detected symmetrically in both directions. + """ + + def test_tweezer_bottom_detects_small_matching_low_gap(self): + c2 = ohlc(100, 101, 99, 100) + c1 = ohlc(105, 106, 95.0, 96) # bearish, low=95.0 + c0 = ohlc(96, 97, 95.02, 96.5) # bullish, low=95.02 -> gap ~0.021% + assert detect_candlestick_patterns([c2, c1, c0]) == pytest.approx(1.5) + + def test_tweezer_top_detects_small_matching_high_gap(self): + c2 = ohlc(100, 101, 99, 100) + c1 = ohlc(95, 106.0, 94, 105) # bullish, high=106.0 + c0 = ohlc(105, 105.98, 94, 95) # bearish, high=105.98 -> gap ~0.019% + assert detect_candlestick_patterns([c2, c1, c0]) == pytest.approx(-1.5) + + +class TestOrderBlockDisplacementAndVolume: + """Regression tests for fix (vv): any 2-candle same-direction sequence + after the OB candle used to count as a valid order block, even if + price never actually cleared that candle's own high/low — the + "imbalance" a real SMC order block is supposed to leave behind wasn't + checked at all, and `strength` was hardcoded to 1. Now requires actual + displacement past the OB candle's range, and boosts (never gates on) + strength when volume and/or displacement are unusually large. + """ + + def _candles_with_ob(self, confirm_high=None, confirm_low=None, volume_spike=False): + # Bullish OB candidate: bearish OB candle at some index, followed + # by 2 bullish confirming candles. + base = [ohlc(100, 101, 99, 100, volume=100) for _ in range(20)] + ob_candle = candle(105, 95, 96, volume=100) # bearish (open > close conceptually via high/low only used) + # candle() here only carries high/low/close/volume (no open) since + # _detect_order_blocks reads curr["open"]/["close"] — build OHLC + # explicitly instead for the 3 candles that matter. + ob = ohlc(104, 105, 95, 96, volume=100) # bearish: close(96) < open(104) + nxt1_close = confirm_high - 1 if confirm_high else 106 + nxt1 = ohlc(97, nxt1_close + 1, 96, nxt1_close, volume=200 if volume_spike else 100) + nxt2_close = confirm_high if confirm_high else 107 + nxt2 = ohlc(nxt1_close, nxt2_close + 1, nxt1_close - 1, nxt2_close, volume=200 if volume_spike else 100) + return base + [ob, nxt1, nxt2] + + def test_bullish_ob_requires_displacement_past_ob_high(self): + # Confirming candles close ABOVE the OB candle's own high (105) -> valid. + candles = self._candles_with_ob(confirm_high=110) + blocks = _detect_order_blocks(candles, lookback=20) + assert any(b["type"] == "BULLISH" for b in blocks) + + def test_bullish_ob_rejected_when_confirmation_stays_inside_ob_range(self): + # Confirming candles close ABOVE their own open (still "bullish" + # candles) but never clear the OB candle's high (105) — the old + # code accepted this; the fix requires real displacement. + base = [ohlc(100, 101, 99, 100, volume=100) for _ in range(20)] + ob = ohlc(104, 105, 95, 96, volume=100) + nxt1 = ohlc(96.2, 96.8, 96.0, 96.6, volume=100) # bullish but tiny, inside OB range + nxt2 = ohlc(96.6, 97.5, 96.5, 97.0, volume=100) # bullish but still under OB high (105) + candles = base + [ob, nxt1, nxt2] + blocks = _detect_order_blocks(candles, lookback=20) + assert not any(b["type"] == "BULLISH" for b in blocks) + + def test_strength_increases_with_volume_confirmation(self): + candles_no_spike = self._candles_with_ob(confirm_high=110, volume_spike=False) + candles_with_spike = self._candles_with_ob(confirm_high=110, volume_spike=True) + + blocks_no_spike = [b for b in _detect_order_blocks(candles_no_spike, lookback=20) if b["type"] == "BULLISH"] + blocks_with_spike = [b for b in _detect_order_blocks(candles_with_spike, lookback=20) if b["type"] == "BULLISH"] + + assert blocks_no_spike and blocks_with_spike + assert blocks_with_spike[-1]["strength"] > blocks_no_spike[-1]["strength"] diff --git a/theo_doi_trading-portal_v14.md b/theo_doi_trading-portal_v14.md new file mode 100644 index 0000000..af943dc --- /dev/null +++ b/theo_doi_trading-portal_v14.md @@ -0,0 +1,80 @@ +# Theo dõi đánh giá dự án Trading Portal — v14 + +> **Ngày đánh giá gốc:** 2026-07-03 +> **Cập nhật v1-v13:** xem các file `theo_doi_trading-portal_v1.md`…`v13.md` +> **Cập nhật v14 (lần này):** 2026-07-04 — triển khai 2 vấn đề ưu tiên thấp còn lại: (vv) Order Block detection thêm điều kiện displacement/volume, (ww) sửa lỗi epsilon chia-0 không nhất quán trong pattern Tweezer Bottom +> **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_v15.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) và Nhóm B (v13), người dùng yêu cầu làm nốt 2 mục ưu tiên thấp còn lại trong danh sách audit gốc trước khi tạm dừng: (vv) Order Block detection quá đơn giản, (ww) ngưỡng candlestick pattern chưa kiểm định. Với (ww), thay vì đoán lại ngưỡng "đúng" cho 30+ pattern nến (việc đó cần thực nghiệm giống mục tt), đã đào sâu lại phát hiện gốc của audit và tìm ra đây thực chất là **một lỗi code cụ thể** (bất nhất trong cách xử lý chia-0 giữa 2 công thức gần giống hệt nhau) chứ không chỉ là "ngưỡng chưa tối ưu" — sửa được dứt điểm mà không cần dữ liệu thực nghiệm. + +--- + +## 3. Các thay đổi trong v14 + +### 3.1 (vv) Order Block detection: thêm điều kiện displacement + volume + +**Vấn đề:** `_detect_order_blocks()` coi BẤT KỲ chuỗi 2 nến cùng chiều sau nến "OB candle" là hợp lệ, kể cả khi giá chưa từng thực sự vượt ra khỏi vùng giá (high/low) của chính nến OB đó — tức là thiếu hẳn khái niệm "imbalance" (khoảng trống chưa lấp) vốn là bản chất định nghĩa của một Order Block theo trường phái SMC thật sự. `strength` cũng luôn cố định = 1 bất kể mức độ tin cậy. + +**Đã sửa** (`backend/app/services/indicator_service.py`): +- Thêm điều kiện **displacement**: nến xác nhận thứ 2 (`nxt2`) phải đóng cửa VƯỢT QUA high (cho Bullish OB) hoặc low (cho Bearish OB) của chính nến OB — đây là một lỗ hổng logic có thật (không phải "ngưỡng chưa tối ưu"), vì trước đây một chuỗi nến tăng/giảm rất yếu, còn nằm HOÀN TOÀN bên trong vùng giá của nến OB, vẫn được tính là Order Block hợp lệ. +- Thêm **volume confirmation**: nếu volume của `nxt1` hoặc `nxt2` vượt 1.2× volume trung bình cục bộ (so với chính lịch sử gần đây của symbol đó, không phải số tuyệt đối), tăng `strength`. Volume chỉ **tăng điểm tin cậy**, không phải điều kiện bắt buộc — tránh trường hợp dữ liệu backtest/test tổng hợp có volume phẳng làm toàn bộ Order Block bị loại bỏ oan. +- `strength` giờ phản ánh thực sự: 1 (cơ bản) + 1 nếu có volume xác nhận + 1 nếu displacement vượt quá cả độ rộng (range) của chính nến OB — tối đa 3. + +**Test mới** (`tests/test_indicator_service.py`, class `TestOrderBlockDisplacementAndVolume`): xác nhận Order Block hợp lệ khi có displacement thật; bị loại khi chuỗi nến xác nhận vẫn nằm trong vùng giá nến OB (test trực tiếp lỗ hổng cũ); `strength` tăng khi có volume đột biến so với không có. + +### 3.2 (ww) Sửa lỗi epsilon chia-0 không nhất quán trong Tweezer Bottom + +**Vấn đề:** Sau khi đào sâu lại đúng dòng code audit đã chỉ ra, phát hiện đây là MỘT LỖI CODE THẬT (không phải chỉ là ngưỡng "0.1%" chưa kiểm định): công thức Tweezer Top dùng `max(h1, h0)`, trong khi công thức Tweezer Bottom (ngay dòng dưới, cấu trúc giống hệt) lại dùng `max(l1 + 0.001, l0)` — cộng thêm 0.001 chỉ ở một phía. Cả 2 công thức đã có sẵn guard `> 0` trước khi chia, nên epsilon này chưa bao giờ cần thiết để tránh chia cho 0 — nó chỉ đơn thuần làm 2 công thức lẽ ra phải đối xứng lại xử lý khác nhau. + +**Đã sửa** (`backend/app/services/indicator_service.py`): xóa `+ 0.001` khỏi công thức Tweezer Bottom, khớp đúng với Tweezer Top. + +**Lưu ý về tác động thực tế:** vì epsilon (0.001 đơn vị giá tuyệt đối) cực nhỏ so với thang giá thực tế (hàng chục đến hàng chục nghìn USD), tác động THỰC SỰ lên kết quả trade gần như bằng 0 trong mọi trường hợp thực tế — đây là một fix về **tính nhất quán/đúng đắn của code**, không phải một cải thiện hiệu quả trade đáng kể. Khác với các mục ưu tiên cao/trung bình trước đó, mục này chủ yếu mang tính dọn dẹp kỹ thuật. + +**Test mới** (`tests/test_indicator_service.py`, class `TestCandlestickTweezerSymmetry`): xác nhận Tweezer Top và Tweezer Bottom phát hiện đối xứng với cùng độ nhạy (gap nhỏ ~0.02% được cả 2 hướng chấp nhận như nhau). + +### 3.3 Kiểm chứng + +Toàn bộ 237 test backend pass (tăng từ 232 ở v13 lên 237 — thêm 3 test Order Block displacement/volume, 2 test Tweezer symmetry). Không đổi gì ở frontend trong đợt này. + +--- + +## 4. Tổng kết toàn bộ chu kỳ audit 13 thuật toán + hệ thống risk/execution (v10 → v14) + +Toàn bộ danh sách vấn đề phát hiện từ audit ban đầu (mục "Danh sách ưu tiên" trong báo cáo audit gốc) nay đã được xử lý, TRỪ 2 mục cần quyết định đầu tư lớn hơn một buổi làm việc: + +| # | Vấn đề | Trạng thái | +|---|---|---| +| a-r | (đã xử lý ở các phiên trước v10) | ✅ | +| Fee/slippage backtest, FIFO PnL thật, pivot delay | v10 | ✅ | +| ii, hh, jj, kk, ll | v11 | ✅ | +| mm, ss, nn, oo, xx, rr | v12 (Nhóm A) | ✅ | +| uu, qq, pp | v13 (Nhóm B) | ✅ | +| vv, ww | v14 | ✅ | +| **tt** | Tối ưu thực nghiệm toàn bộ tham số 13 thuật toán | ⏳ Cần chạy walk-forward thật trên dữ liệu lịch sử + mở rộng kiến trúc `walk_forward.py` để tối ưu cả tham số chỉ báo (không chỉ 3 tham số threshold hiện tại) | +| **yy** | Portfolio backtest đa vị thế (mô phỏng eviction MAX_OPEN_TRADES qua nhiều symbol) | ⏳ Cần một portfolio-backtest engine mới, thay đổi kiến trúc lớn | + +**Tổng số test backend:** 170 (v9) → 237 (v14), tăng 67 test qua toàn bộ chu kỳ audit. + +--- + +## 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. | +| v14 | 2026-07-04 | (vv) Order Block thêm displacement/volume confirmation, (ww) sửa lỗi epsilon không nhất quán trong Tweezer Bottom. 237 test pass (+5). Toàn bộ audit 13 thuật toán hoàn tất, chỉ còn (tt) và (yy) cần quyết định đầu tư riêng (thực nghiệm dữ liệu thật / kiến trúc portfolio backtest mới). | \ No newline at end of file