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). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kby7M9JwpYi8NQT2yoxAi
This commit is contained in:
+318
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user