Add chart-pattern entry archetype (XAU_CP) — disabled by default

Third entry archetype: completion of classical chart patterns (double
bottom/top, H&S top / inverse H&S, bull/bear flag) — the tight canon
with the strongest documented stats. New chart_patterns.py detector
module (pure functions, ATR-unit geometry, closed-bar slices only,
first-close-beyond trigger discipline so a breakout fires exactly
once and never repaints).

Pipeline step 10 in analyze_from_dataframes: fills only empty setup
slots, allows weak-composite F grades (normal at reversal completion)
but inherits session/day blocks, mirrors MTF-opposition + SELL
vol/hour + M5 protections. Geometry: SL = pattern anchor -/+ 0.5 ATR
(min $8), TP1 = measured move, $9 spread gate, trailing exit does the
real work. Strategy tag XAU_CP.

cp_enabled defaults to False everywhere — per-TF adoption happens in
pair_signals/xauusd.py only after the standard validation rule passes.
Backtester: --cp / --cp-patterns flags to test the candidate, XAU_CP
tag recognition.

Suite: 183/183 (21 new tests: detectors, trigger discipline,
invalidation, gates, pipeline, default-off safety).

Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
This commit is contained in:
Harwinder Sekhon
2026-07-16 14:23:17 -04:00
parent 14448c10e8
commit f3d3e56aa8
5 changed files with 801 additions and 1 deletions
+15 -1
View File
@@ -1541,6 +1541,8 @@ def _replay_span(span: Tuple[int, int]):
first_bear = report.bearish_reasons[0] if report.bearish_reasons else ""
if "XAU PULLBACK" in first_bull or "XAU PULLBACK" in first_bear:
tag = "XAU_PB"
elif "XAU CHART PATTERN" in first_bull or "XAU CHART PATTERN" in first_bear:
tag = "XAU_CP"
elif "XAU STRATEGY" in first_bull:
tag = "XAU"
@@ -2141,7 +2143,13 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)")
p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
p.add_argument("--strategies", default="",
help="Comma-separated strategy tags to include (XAU,XAU_PB,GENERIC). Empty = all.")
help="Comma-separated strategy tags to include (XAU,XAU_PB,XAU_CP,GENERIC). Empty = all.")
p.add_argument("--cp", action="store_true",
help="Force-enable the chart-pattern entry (XAU_CP) regardless "
"of the factory default — for validating the candidate.")
p.add_argument("--cp-patterns", default=None,
help="Comma-separated pattern subset for --cp (e.g. "
"double_bottom,bull_flag). Default: all detectors.")
p.add_argument("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
p.add_argument("--spread", type=float, default=None,
help="Round-trip cost in price units per trade (default: pair's "
@@ -2208,6 +2216,12 @@ def main(argv: Optional[List[str]] = None) -> int:
)
if args.swing_sr_lookback > 0:
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
if args.cp:
platform_cfg.xau_strategy.cp_enabled = True
if args.cp_patterns:
platform_cfg.xau_strategy.cp_patterns = [
s.strip() for s in args.cp_patterns.split(",") if s.strip()
]
confirm_tfs = list(platform_cfg.confirmation_timeframes)
end = (
+287
View File
@@ -0,0 +1,287 @@
"""
Classical chart-pattern detection for the XAU_CP entry archetype.
================================================================
Third entry archetype (after the composite-breakout generators and the
XAU_PB pullback): trade the completion of a proven multi-bar price
pattern. Pattern set is deliberately tight — the classical patterns
with the strongest documented statistics (Bulkowski, *Encyclopedia of
Chart Patterns*) that are unambiguous from OHLC at bar close:
double_bottom bullish reversal two matching swing lows, break of
the interim peak (neckline)
double_top bearish reversal mirrored
inv_hs bullish reversal inverse head & shoulders, break of
the neckline (max of the two peaks
flanking the head — conservative
horizontal line, no sloped fits)
hs_top bearish reversal head & shoulders top, mirrored
bull_flag bullish continuation strong pole, shallow ordered
retrace, close at a new high
bear_flag bearish continuation mirrored
Triangles / wedges / cup-and-handle are excluded on purpose: weaker
historical stats and apex-timing ambiguity — more parameters for less
edge.
Design contract (mirrors the rest of the signal stack):
- Detectors see ONLY the visible closed-bar slice the aggregator was
given; the last row is the just-closed signal bar. No forming candle,
no future bars → identical behavior live and in the bar-replay
backtester, and signals never repaint.
- All geometric thresholds are in ATR units so one parameter set is
scale-free across timeframes. ATR comes from the engine (Wilder 14).
- Trigger discipline: a pattern fires only on the bar that COMPLETES it
— the first close beyond the neckline/boundary since the pattern
formed. One breakout → at most one signal.
- Swing points use the same local-extremum definition as the engine's
structure S/R (window w bars each side), so a "swing" here is the
same object a chartist sees on the engine's key levels.
Every detector returns None or a dict:
{"pattern": str, "direction": "BUY"|"SELL",
"anchor": float, # SL reference (pattern invalidation price)
"trigger": float, # neckline / boundary that was broken
"height": float} # measured-move distance in price units
Defaults below are the textbook definitions, not tuned values. The
validation record for which patterns/timeframes are enabled lives in
pair_signals/xauusd.py (per repo rules, per-TF adoption happens in the
factory, never in global defaults).
"""
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
__all__ = ["detect_chart_pattern", "PATTERN_DETECTORS"]
def _swing_indices(values: np.ndarray, w: int, find_high: bool) -> List[int]:
"""Indices of local extrema with `w` bars on each side.
A bar i is a swing high if values[i] is the strict maximum of
values[i-w : i+w+1] (ties reject — a flat top is not a clean swing).
The last `w` bars can never qualify, which is exactly right: a swing
isn't confirmed until `w` bars close after it.
"""
n = len(values)
out: List[int] = []
for i in range(w, n - w):
window = values[i - w : i + w + 1]
if np.isnan(window).any():
continue
center = values[i]
rest = np.delete(window, w)
if find_high:
if center > rest.max():
out.append(i)
else:
if center < rest.min():
out.append(i)
return out
def _first_close_beyond(closes: np.ndarray, start: int, level: float,
above: bool) -> bool:
"""True iff the LAST close is the first one beyond `level` since
`start` (exclusive of the last bar)."""
last = closes[-1]
prior = closes[start:-1]
if above:
return last > level and (len(prior) == 0 or prior.max() <= level)
return last < level and (len(prior) == 0 or prior.min() >= level)
# ── Double bottom / top ──────────────────────────────────────────────
def _detect_double(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]:
w = cfg.cp_swing_window
ext = l if bullish else h
idx = _swing_indices(ext, w, find_high=not bullish)
if len(idx) < 2:
return None
i1, i2 = idx[-2], idx[-1]
n = len(c)
if i2 - i1 < cfg.cp_min_sep_bars:
return None
if (n - 1) - i2 > cfg.cp_recency_bars:
return None
e1, e2 = ext[i1], ext[i2]
if abs(e1 - e2) > cfg.cp_tolerance_atr * atr:
return None
if bullish:
neckline = float(h[i1 : i2 + 1].max())
floor = min(e1, e2)
height = neckline - floor
else:
neckline = float(l[i1 : i2 + 1].min())
ceil_ = max(e1, e2)
height = ceil_ - neckline
if not (cfg.cp_min_height_atr * atr <= height <= cfg.cp_max_height_atr * atr):
return None
# Pattern must not have been invalidated after the second extreme:
# a lower low (bullish) / higher high (bearish) breaks the pattern.
if bullish:
if i2 + 1 < n and l[i2 + 1 :].min() < min(e1, e2) - 0.1 * atr:
return None
if not _first_close_beyond(c, i2, neckline, above=True):
return None
return {"pattern": "double_bottom", "direction": "BUY",
"anchor": float(min(e1, e2)), "trigger": neckline,
"height": float(height)}
else:
if i2 + 1 < n and h[i2 + 1 :].max() > max(e1, e2) + 0.1 * atr:
return None
if not _first_close_beyond(c, i2, neckline, above=False):
return None
return {"pattern": "double_top", "direction": "SELL",
"anchor": float(max(e1, e2)), "trigger": neckline,
"height": float(height)}
# ── Head & shoulders (top and inverse) ───────────────────────────────
def _detect_hs(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]:
"""bullish=True → inverse H&S (three swing LOWS, head deepest)."""
w = cfg.cp_swing_window
ext = l if bullish else h
idx = _swing_indices(ext, w, find_high=not bullish)
if len(idx) < 3:
return None
i1, i2, i3 = idx[-3], idx[-2], idx[-1]
n = len(c)
if (n - 1) - i3 > cfg.cp_recency_bars:
return None
s1, head, s2 = ext[i1], ext[i2], ext[i3]
if bullish:
# Head strictly the deepest low; shoulders roughly level.
if not (head < s1 - cfg.cp_hs_head_min_atr * atr
and head < s2 - cfg.cp_hs_head_min_atr * atr):
return None
if abs(s1 - s2) > cfg.cp_hs_shoulder_tol_atr * atr:
return None
# Neckline: the LOWER of the two interim peaks — the price must
# clear both, so this is the conservative horizontal trigger.
p1 = float(h[i1 : i2 + 1].max())
p2 = float(h[i2 : i3 + 1].max())
neckline = min(p1, p2)
height = neckline - head
else:
if not (head > s1 + cfg.cp_hs_head_min_atr * atr
and head > s2 + cfg.cp_hs_head_min_atr * atr):
return None
if abs(s1 - s2) > cfg.cp_hs_shoulder_tol_atr * atr:
return None
t1 = float(l[i1 : i2 + 1].min())
t2 = float(l[i2 : i3 + 1].min())
neckline = max(t1, t2)
height = head - neckline
if not (cfg.cp_min_height_atr * atr <= height <= cfg.cp_max_height_atr * atr):
return None
if bullish:
# Invalidated if price undercut the head after the right shoulder.
if i3 + 1 < n and l[i3 + 1 :].min() < head - 0.1 * atr:
return None
if not _first_close_beyond(c, i3, neckline, above=True):
return None
return {"pattern": "inv_hs", "direction": "BUY",
"anchor": float(s2), "trigger": neckline,
"height": float(height)}
else:
if i3 + 1 < n and h[i3 + 1 :].max() > head + 0.1 * atr:
return None
if not _first_close_beyond(c, i3, neckline, above=False):
return None
return {"pattern": "hs_top", "direction": "SELL",
"anchor": float(s2), "trigger": neckline,
"height": float(height)}
# ── Flags (bull and bear) ────────────────────────────────────────────
def _detect_flag(o, h, l, c, atr, cfg, bullish: bool) -> Optional[Dict]:
n = len(c)
lo_r, hi_r = cfg.cp_flag_retrace
if bullish:
# Pole top = highest high BEFORE the trigger bar.
ip = int(np.argmax(h[:-1]))
flag_len = (n - 1) - ip
if not (cfg.cp_flag_min_bars <= flag_len <= cfg.cp_flag_max_bars):
return None
pole_start = max(0, ip - cfg.cp_flag_pole_bars + 1)
pole_low = float(l[pole_start : ip + 1].min())
pole = float(h[ip]) - pole_low
if pole < cfg.cp_flag_pole_atr * atr:
return None
flag_low = float(l[ip + 1 : n].min())
retrace = (float(h[ip]) - flag_low) / pole
if not (lo_r <= retrace <= hi_r):
return None
if not _first_close_beyond(c, ip, float(h[ip]), above=True):
return None
return {"pattern": "bull_flag", "direction": "BUY",
"anchor": flag_low, "trigger": float(h[ip]),
"height": float(pole)}
else:
ip = int(np.argmin(l[:-1]))
flag_len = (n - 1) - ip
if not (cfg.cp_flag_min_bars <= flag_len <= cfg.cp_flag_max_bars):
return None
pole_start = max(0, ip - cfg.cp_flag_pole_bars + 1)
pole_high = float(h[pole_start : ip + 1].max())
pole = pole_high - float(l[ip])
if pole < cfg.cp_flag_pole_atr * atr:
return None
flag_high = float(h[ip + 1 : n].max())
retrace = (flag_high - float(l[ip])) / pole
if not (lo_r <= retrace <= hi_r):
return None
if not _first_close_beyond(c, ip, float(l[ip]), above=False):
return None
return {"pattern": "bear_flag", "direction": "SELL",
"anchor": flag_high, "trigger": float(l[ip]),
"height": float(pole)}
# Priority order: most structured / rarest first — if two patterns
# complete on the same bar, the more specific one names the trade.
PATTERN_DETECTORS = {
"inv_hs": lambda o, h, l, c, atr, cfg: _detect_hs(o, h, l, c, atr, cfg, True),
"hs_top": lambda o, h, l, c, atr, cfg: _detect_hs(o, h, l, c, atr, cfg, False),
"double_bottom": lambda o, h, l, c, atr, cfg: _detect_double(o, h, l, c, atr, cfg, True),
"double_top": lambda o, h, l, c, atr, cfg: _detect_double(o, h, l, c, atr, cfg, False),
"bull_flag": lambda o, h, l, c, atr, cfg: _detect_flag(o, h, l, c, atr, cfg, True),
"bear_flag": lambda o, h, l, c, atr, cfg: _detect_flag(o, h, l, c, atr, cfg, False),
}
def detect_chart_pattern(df: pd.DataFrame, atr: float, cfg) -> Optional[Dict]:
"""Run enabled detectors over the last cp_lookback(+1) bars.
`df` is the visible history at decision time (last row = signal
bar). Returns the first match in PATTERN_DETECTORS priority order,
or None.
"""
if atr <= 0 or len(df) < cfg.cp_lookback + 1:
return None
tail = df.iloc[-(cfg.cp_lookback + 1):]
o = tail["Open"].to_numpy(dtype=np.float64)
h = tail["High"].to_numpy(dtype=np.float64)
l = tail["Low"].to_numpy(dtype=np.float64)
c = tail["Close"].to_numpy(dtype=np.float64)
enabled = set(cfg.cp_patterns)
for name, det in PATTERN_DETECTORS.items():
if name not in enabled:
continue
hit = det(o, h, l, c, atr, cfg)
if hit is not None:
return hit
return None
+28
View File
@@ -274,6 +274,34 @@ class XauStrategyConfig:
# Price must have touched EMA21/KC-mid within this many bars.
m5_pullback_touch_bars: int = 3
# ── Chart-pattern entry (XAU_CP) ──────────────────────────────
# Third entry archetype: completion of a classical chart pattern
# (see chart_patterns.py for the pattern set and design contract).
# Defaults are textbook geometry, NOT tuned values. Disabled by
# default — per-TF adoption happens in pair_signals/xauusd.py only
# after the pattern book passes the standard validation rule
# (positive R both halves of ≥12mo, PF ≥ 1.3, $0.30 spread,
# trailing exits, next-open fills).
cp_enabled: bool = False
cp_patterns: List[str] = field(default_factory=lambda: [
"inv_hs", "hs_top", "double_bottom", "double_top",
"bull_flag", "bear_flag",
])
cp_lookback: int = 72 # bars scanned for pattern structure
cp_swing_window: int = 3 # bars each side to confirm a swing
cp_recency_bars: int = 20 # last extreme must be this fresh
cp_min_sep_bars: int = 8 # min bars between double extremes
cp_tolerance_atr: float = 0.5 # double top/bottom level match tol
cp_min_height_atr: float = 1.5 # pattern height floor (noise gate)
cp_max_height_atr: float = 8.0 # pattern height ceiling
cp_hs_head_min_atr: float = 0.5 # head must exceed shoulders by this
cp_hs_shoulder_tol_atr: float = 1.0 # shoulder level match tol
cp_flag_pole_atr: float = 3.0 # min pole size
cp_flag_pole_bars: int = 12 # pole measured over ≤ this many bars
cp_flag_min_bars: int = 3 # flag consolidation length bounds
cp_flag_max_bars: int = 12
cp_flag_retrace: tuple = (0.1, 0.5) # flag depth as fraction of pole
@dataclass
class FilterConfig:
+153
View File
@@ -13,6 +13,7 @@ from typing import Dict, List, Optional
import pandas as pd
from chart_patterns import detect_chart_pattern
from config import PlatformConfig, SignalStrength, Timeframe
from data_provider import ForexDataProvider
from engine import TechnicalEngine, TechnicalAnalysis
@@ -98,6 +99,8 @@ class SignalAggregator:
# Diagnostic funnel for the M5 pullback route (shared across
# instances; read by backtest tooling, harmless in live use).
pb_funnel: Dict[str, int] = {}
# Same, for the chart-pattern route (XAU_CP).
cp_funnel: Dict[str, int] = {}
def __init__(self, config: PlatformConfig):
self.config = config
@@ -563,6 +566,138 @@ class SignalAggregator:
target = report.bullish_reasons if is_buy else report.bearish_reasons
target.insert(0, marker)
# ── Chart-pattern entry (XAU_CP) ────────────────────────────────
def _try_chart_pattern(
self, report: SignalReport, primary_df: pd.DataFrame, pip_value: float
) -> None:
"""Build an XAU_CP trade setup if a completed chart pattern
passes the pair-level protections.
Unlike the composite paths there is no minimum-score gate: a
reversal pattern completes exactly where the composite is still
pointing the old way. Protection comes from the pattern's own
structural gates plus the validated directional filters below.
"""
cfg = self.config.xau_strategy
funnel = SignalAggregator.cp_funnel
def _reject(reason: str) -> None:
funnel[reason] = funnel.get(reason, 0) + 1
pa = report.primary_analysis
if pa is None:
return
atr = pa.key_levels.get("atr", 0.0)
# Same $-ATR floor the swing generator applies (dead tape).
if atr < cfg.min_atr_threshold:
return
pat = detect_chart_pattern(primary_df, atr, cfg)
if pat is None:
return
_reject("detected")
direction = pat["direction"]
is_buy = direction == "BUY"
# Never against multi-TF consensus (mirror of gate 6f).
sigs = list(report.mtf_signals.values())
bull_n = sum(1 for s in sigs if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY))
bear_n = sum(1 for s in sigs if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL))
thr = self.config.signals.mtf_agreement_threshold
if (is_buy and bear_n >= thr) or (not is_buy and bull_n >= thr):
_reject("mtf_opposition")
return
# Directional protections — gold's macro-uptrend asymmetry is
# structural, so the validated SELL gates apply to pattern
# shorts too (quiet-vol shorts fade, blocked SELL hours lose).
if not is_buy:
if (report.volatility_regime
and report.volatility_regime not in cfg.sell_vol_regimes):
_reject("sell_vol")
return
if cfg.sell_blocked_hours_utc:
try:
close_h = (
pd.Timestamp(report.timestamp)
+ pd.Timedelta(
seconds=ForexDataProvider.CANDLE_SECONDS[
self.config.primary_timeframe
]
)
).hour
except Exception:
close_h = None
if close_h in cfg.sell_blocked_hours_utc:
_reject("sell_hour")
return
if self.config.primary_timeframe == Timeframe.M5:
rsi_ind = pa.indicators.get("rsi")
if rsi_ind is not None and rsi_ind.value < cfg.m5_sell_min_rsi:
_reject("m5_sell_rsi")
return
# M5 chop-transition volatility block (validated, both ways).
if (self.config.primary_timeframe == Timeframe.M5
and report.volatility_regime in cfg.m5_blocked_vol_regimes):
_reject("vol_regime")
return
entry = report.current_price
min_sl_price = self.config.signals.min_sl_pips * pip_value
height = pat["height"]
if is_buy:
sl_dist = max(entry - (pat["anchor"] - 0.5 * atr), min_sl_price)
sl = entry - sl_dist
tp1 = entry + height
tp2 = entry + 1.5 * height
else:
sl_dist = max((pat["anchor"] + 0.5 * atr) - entry, min_sl_price)
sl = entry + sl_dist
tp1 = entry - height
tp2 = entry - 1.5 * height
# The measured move IS the pattern target — if it can't clear
# the RR floor or the spread gate, the pattern is untradeable.
rr = height / sl_dist if sl_dist > 0 else 0.0
if rr < self.config.signals.min_rr_ratio - 1e-9:
_reject("rr")
return
min_tp = (self.config.signals.round_trip_cost
* self.config.signals.min_tp_cost_multiple)
if height < min_tp:
_reject("spread_gate")
return
report.trade_setup = TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction=direction,
entry_price=entry,
stop_loss=sl,
take_profit_1=tp1,
take_profit_2=tp2,
risk_reward=round(rr, 2),
risk_pips=sl_dist / pip_value,
reward_pips=height / pip_value,
pip_value=pip_value,
strategy="XAU_CP",
)
if report.signal_quality not in ("A+", "A", "B"):
report.signal_quality = "B"
report.final_signal = (SignalStrength.BUY if is_buy
else SignalStrength.SELL)
report.action_recommendation = (
f"{direction} — {pat['pattern']} pattern completion"
)
marker = (f"XAU CHART PATTERN: {pat['pattern']} "
f"({direction}) break of {pat['trigger']:.2f}, "
f"height {height / atr:.1f} ATR")
target = report.bullish_reasons if is_buy else report.bearish_reasons
target.insert(0, marker)
_reject("accepted")
# ── Public: bar-replayable entry point ─────────────────────────
def analyze_from_dataframes(
@@ -825,6 +960,24 @@ class SignalAggregator:
and report.signal_quality != "F"):
self._try_m5_pullback(report, primary_df, pip_value)
# ── 10. Chart-pattern entry (XAU_CP) ────────────────────────
# Third archetype: completion of a classical chart pattern
# (double top/bottom, H&S, flag — see chart_patterns.py). Only
# fills empty slots (core + pullback setups take precedence).
# Unlike XAU_PB this must NOT require quality != "F": a weak
# composite (grade F on |score| < 15) is the normal state at a
# reversal-pattern completion. Session/day blocks still bind —
# they are detected by their warnings (and the live wrapper +
# backtest replay apply the bar-close session predicate again
# on top, so a pattern setup can never trade a blocked hour).
if (report.display_name == "XAU/USD"
and self.config.xau_strategy.cp_enabled
and report.trade_setup is None
and not any(w.startswith(("SESSION FILTER", "DAY FILTER",
"WEEKEND FILTER"))
for w in report.warnings)):
self._try_chart_pattern(report, primary_df, pip_value)
return report
# ── Internals ──────────────────────────────────────────────────
+318
View File
@@ -2170,6 +2170,324 @@ def _():
assert t2.entry_time == idx[2] and t2.entry_price == 110.0 and t2.sl == 102.0
# ══════════════════════════════════════════════════════════════════════
# MODULE 12: Chart-pattern entry (XAU_CP)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 12: chart patterns (XAU_CP) ═══")
from chart_patterns import detect_chart_pattern
from config import XauStrategyConfig
def make_cp_df(closes, end="2026-07-08 14:00", freq="1h", wick=1.5):
"""Deterministic OHLCV from an explicit close path (fixed end time:
Wed, label hour 14, close hour 15 — clear of every session block)."""
idx = pd.date_range(end=end, periods=len(closes), freq=freq, tz="UTC")
c = np.asarray(closes, dtype=float)
o = np.concatenate([[c[0]], c[:-1]])
return pd.DataFrame({"Open": o, "High": c + wick, "Low": c - wick,
"Close": c, "Volume": 1000.0}, index=idx)
def mirror_cp_df(df, pivot=6000.0):
"""Price-mirror a fixture: bullish pattern → exact bearish twin."""
return pd.DataFrame({
"Open": pivot - df["Open"], "High": pivot - df["Low"],
"Low": pivot - df["High"], "Close": pivot - df["Close"],
"Volume": df["Volume"]}, index=df.index)
def double_bottom_closes():
"""L1@30 (2984) and L2@65 (2984.5), neckline peak 3000 @45,
below-neckline drift, breakout close 3004 on the last bar."""
return (list(np.linspace(2988, 3013, 15))
+ list(np.linspace(3011, 2984, 16))
+ list(np.linspace(2986, 3000, 15))
+ list(np.linspace(2999, 2984.5, 20))
+ list(np.linspace(2986, 2998, 13))
+ [3004.0])
def hs_top_closes():
"""S1@20 (3000), head@38 (3010), S2@62 (2999), neckline ~2989.5,
breakdown close 2988 on the last bar."""
return (list(np.linspace(2982, 3000, 21))
+ list(np.linspace(2998.5, 2990, 7))
+ list(np.linspace(2992, 3010, 11))
+ list(np.linspace(3008, 2991, 8))
+ list(np.linspace(2992, 2999, 16))
+ list(np.linspace(2998.5, 2991, 16))
+ [2988.0])
def bull_flag_closes():
"""Pole 60→69 (3000→3025), flag drift 70→78, breakout close 3028.5."""
return (list(np.linspace(3000, 2996, 60))
+ list(np.linspace(2999, 3025, 10))
+ list(np.linspace(3023, 3018, 9))
+ [3028.5])
_CP_CFG = XauStrategyConfig()
@test("CP: double bottom detected on breakout bar with correct geometry")
def _():
df = make_cp_df(double_bottom_closes())
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
assert hit is not None, "double bottom not detected"
assert hit["pattern"] == "double_bottom" and hit["direction"] == "BUY"
assert abs(hit["anchor"] - 2982.5) < 1e-9, f"anchor {hit['anchor']}"
assert abs(hit["trigger"] - 3001.5) < 1e-9, f"trigger {hit['trigger']}"
assert abs(hit["height"] - 19.0) < 1e-9, f"height {hit['height']}"
@test("CP: double top detected on mirrored fixture")
def _():
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
assert hit is not None and hit["pattern"] == "double_top"
assert hit["direction"] == "SELL"
assert abs(hit["anchor"] - (6000 - 2982.5)) < 1e-9
assert abs(hit["trigger"] - (6000 - 3001.5)) < 1e-9
@test("CP: trigger discipline — no signal before breakout close")
def _():
df = make_cp_df(double_bottom_closes()).iloc[:-1]
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
@test("CP: trigger discipline — no re-fire on the bar after breakout")
def _():
df = make_cp_df(double_bottom_closes() + [3006.0])
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
@test("CP: double bottom rejected when lows differ beyond tolerance")
def _():
closes = double_bottom_closes()
closes[46:66] = list(np.linspace(2999, 2979, 20)) # L2 5.5 below L1
df = make_cp_df(closes)
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
@test("CP: double bottom invalidated by a lower low after L2")
def _():
closes = double_bottom_closes()
closes[77] = 2978.0 # undercuts both bottoms before the breakout
df = make_cp_df(closes)
assert detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG) is None
@test("CP: head & shoulders top detected (SELL, right-shoulder anchor)")
def _():
df = make_cp_df(hs_top_closes())
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
assert hit is not None, "H&S top not detected"
assert hit["pattern"] == "hs_top" and hit["direction"] == "SELL"
assert abs(hit["anchor"] - 3000.5) < 1e-9, f"anchor {hit['anchor']}"
assert abs(hit["trigger"] - 2989.5) < 1e-9, f"trigger {hit['trigger']}"
@test("CP: inverse head & shoulders detected on mirrored fixture")
def _():
df = mirror_cp_df(make_cp_df(hs_top_closes()))
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
assert hit is not None and hit["pattern"] == "inv_hs"
assert hit["direction"] == "BUY"
@test("CP: bull flag detected (pole, shallow retrace, new-high close)")
def _():
df = make_cp_df(bull_flag_closes())
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
assert hit is not None, "bull flag not detected"
assert hit["pattern"] == "bull_flag" and hit["direction"] == "BUY"
assert abs(hit["trigger"] - 3026.5) < 1e-9
assert hit["height"] > 15.0 # pole ≥ cp_flag_pole_atr × atr
@test("CP: bear flag detected on mirrored fixture")
def _():
df = mirror_cp_df(make_cp_df(bull_flag_closes()))
hit = detect_chart_pattern(df, atr=5.0, cfg=_CP_CFG)
assert hit is not None and hit["pattern"] == "bear_flag"
assert hit["direction"] == "SELL"
@test("CP: cp_patterns subset filter excludes disabled detectors")
def _():
from dataclasses import replace
df = make_cp_df(double_bottom_closes())
cfg = XauStrategyConfig(cp_patterns=["bull_flag"])
assert detect_chart_pattern(df, atr=5.0, cfg=cfg) is None
def _cp_report(df, config, vol_regime="normal", mtf_signals=None):
"""SignalReport shell around a real engine analysis of `df`."""
from engine import TechnicalEngine
eng = TechnicalEngine(config.technical)
pa = eng.analyze(df, "XAU/USD", config.primary_timeframe.value)
rep = SignalReport(symbol="XAU/USD", display_name="XAU/USD",
timestamp=str(df.index[-1]))
rep.primary_analysis = pa
rep.current_price = float(df["Close"].iloc[-1])
rep.volatility_regime = vol_regime
rep.mtf_signals = mtf_signals or {}
rep.signal_quality = "D"
return rep
def _cp_config(**xau_overrides):
cfg = PlatformConfig()
cfg.primary_timeframe = Timeframe.H1
cfg.xau_strategy.cp_enabled = True
for k, v in xau_overrides.items():
setattr(cfg.xau_strategy, k, v)
return cfg
@test("CP gate: BUY pattern builds an XAU_CP setup with measured-move TP")
def _():
cfg = _cp_config()
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg)
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
ts = rep.trade_setup
assert ts is not None, "pattern setup not generated"
assert ts.strategy == "XAU_CP" and ts.direction == "BUY"
assert ts.stop_loss < ts.entry_price
assert abs((ts.take_profit_1 - ts.entry_price) - 19.0) < 1e-6, \
"TP1 must be the measured move"
assert rep.signal_quality == "B"
assert rep.bullish_reasons[0].startswith("XAU CHART PATTERN"), \
"tag marker must sit at reasons[0]"
@test("CP gate: MTF opposition blocks the pattern trade")
def _():
cfg = _cp_config()
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg, mtf_signals={
"15min": SignalStrength.SELL, "4h": SignalStrength.STRONG_SELL})
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None, "BUY pattern must not fire against consensus"
@test("CP gate: SELL pattern blocked in quiet volatility, allowed in high")
def _():
cfg = _cp_config()
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
rep = _cp_report(df, cfg, vol_regime="normal")
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None, "quiet-vol SELL must be blocked"
rep2 = _cp_report(df, cfg, vol_regime="high")
SignalAggregator(cfg)._try_chart_pattern(rep2, df, 0.10)
assert rep2.trade_setup is not None and rep2.trade_setup.direction == "SELL"
@test("CP gate: SELL blocked-hour (bar close) stops pattern shorts only")
def _():
cfg = _cp_config(sell_blocked_hours_utc=[15]) # fixture closes 15:00
df = mirror_cp_df(make_cp_df(double_bottom_closes()))
rep = _cp_report(df, cfg, vol_regime="high")
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None, "SELL at blocked close hour must not fire"
# BUY side unaffected at the same hour.
df_buy = make_cp_df(double_bottom_closes())
rep_buy = _cp_report(df_buy, cfg)
SignalAggregator(cfg)._try_chart_pattern(rep_buy, df_buy, 0.10)
assert rep_buy.trade_setup is not None
@test("CP gate: M5 chop-transition volatility blocks both directions")
def _():
cfg = _cp_config()
cfg.primary_timeframe = Timeframe.M5
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg, vol_regime="high") # 'high' blocked on M5
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None
@test("CP gate: spread gate rejects patterns smaller than TP minimum")
def _():
cfg = _cp_config()
cfg.signals.min_tp_cost_multiple = 1000.0 # $300 minimum target
df = make_cp_df(double_bottom_closes())
rep = _cp_report(df, cfg)
SignalAggregator(cfg)._try_chart_pattern(rep, df, 0.10)
assert rep.trade_setup is None
@test("CP pipeline: fires end-to-end when core generators pass")
def _():
cfg = _cp_config()
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
df = make_cp_df(double_bottom_closes())
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
agg = SignalAggregator(cfg)
with patch.object(SignalAggregator, "_generate_trade_setup",
return_value=None), \
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
return_value=None):
rep = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
assert rep.trade_setup is not None, \
f"pipeline did not produce a setup (warnings: {rep.warnings})"
assert rep.trade_setup.strategy == "XAU_CP"
@test("CP pipeline: disabled by default — no XAU_CP setups anywhere")
def _():
assert XauStrategyConfig().cp_enabled is False
assert PlatformConfig().xau_strategy.cp_enabled is False
from pair_signals import PAIR_PRODUCERS
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
assert PAIR_PRODUCERS[XAUUSD](tf, None).xau_strategy.cp_enabled is False
cfg = PlatformConfig()
cfg.primary_timeframe = Timeframe.H1
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
df = make_cp_df(double_bottom_closes())
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
with patch.object(SignalAggregator, "_generate_trade_setup",
return_value=None), \
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
return_value=None):
rep = SignalAggregator(cfg).analyze_from_dataframes(
"XAU/USD", dfs, use_news=False)
assert rep.trade_setup is None
@test("CP pipeline: session-blocked bar never hosts a pattern trade")
def _():
cfg = _cp_config()
cfg.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
# Label hour 13 is in blocked_hours_utc → 6b sets F + SESSION FILTER.
df = make_cp_df(double_bottom_closes(), end="2026-07-08 13:00")
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
with patch.object(SignalAggregator, "_generate_trade_setup",
return_value=None), \
patch.object(SignalAggregator, "_generate_xau_atr_trade_setup",
return_value=None):
rep = SignalAggregator(cfg).analyze_from_dataframes(
"XAU/USD", dfs, use_news=False)
assert rep.trade_setup is None
assert rep.signal_quality == "F"
@test("CP backtester: --cp flag enables patterns, tag filter knows XAU_CP")
def _():
from gpu_backtest import _parse_args
args = _parse_args(["--pair", "XAUUSD", "--cp",
"--cp-patterns", "double_bottom,bull_flag"])
assert args.cp is True
assert args.cp_patterns == "double_bottom,bull_flag"
args2 = _parse_args(["--pair", "XAUUSD"])
assert args2.cp is False
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════