605faf5310
Add cluster audit pipeline, united EA updates, brochure generators, and publication hygiene (gitignore, MT5 path desensitization, pre-upload scan). Remove tracked reports, models, and binary artifacts from the repo. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""
|
|
Analyze losing trades in market context — bars before/after, gaps between losses,
|
|
RSI/ATR/trend features. Trader-style narrative + param suggestions.
|
|
|
|
Usage:
|
|
python -m cluster_audit.loss_context_analysis united_rsi_scalp_appl
|
|
python -m cluster_audit.loss_context_analysis united_darvas
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import MetaTrader5 as mt5
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from cluster_audit.backtest_core import CostModel, Trade, load_bars, resolve_symbol
|
|
from cluster_audit.engines import ENGINE_MAP
|
|
from cluster_audit.united_registry import PERIODS, UNITED_STRATEGIES
|
|
from indicator_utils import calculate_atr, calculate_ema, calculate_rsi
|
|
|
|
OUT_DIR = Path(__file__).parent / "reports" / "loss_analysis"
|
|
CONTEXT_BARS = 12 # bars before entry + through hold
|
|
|
|
|
|
def find_spec(sid: str) -> dict:
|
|
for s in UNITED_STRATEGIES:
|
|
if s["id"] == sid:
|
|
return s
|
|
raise KeyError(sid)
|
|
|
|
|
|
def run_backtest(spec: dict, start: str, end: str) -> tuple[pd.DataFrame, list[Trade], dict]:
|
|
from cluster_audit.strategy_registry import TF
|
|
|
|
sym = resolve_symbol(spec["symbol"])
|
|
tf_key = spec["tf"]
|
|
engine = ENGINE_MAP[spec["engine"]]
|
|
params = dict(spec["defaults"])
|
|
df = load_bars(sym, TF[tf_key], datetime.fromisoformat(start), datetime.fromisoformat(end))
|
|
report = engine(df, sym, "2021-2026", spec["id"], params, spec["lot"], CostModel.for_symbol(sym))
|
|
return df, report.trades_list, params
|
|
|
|
|
|
def bar_features(df: pd.DataFrame, idx: int, rsi: np.ndarray, atr: np.ndarray, ema20: np.ndarray) -> dict:
|
|
if idx < 1 or idx >= len(df):
|
|
return {}
|
|
o, h, l, c = df.iloc[idx][["open", "high", "low", "close"]]
|
|
prev_c = float(df.iloc[idx - 1]["close"])
|
|
body = abs(c - o)
|
|
rng = h - l if h > l else 1e-9
|
|
return {
|
|
"rsi": float(rsi[idx - 1]) if not np.isnan(rsi[idx - 1]) else np.nan,
|
|
"atr": float(atr[idx - 1]) if not np.isnan(atr[idx - 1]) else np.nan,
|
|
"ema20": float(ema20[idx - 1]) if not np.isnan(ema20[idx - 1]) else np.nan,
|
|
"close": float(c),
|
|
"body_pct": float(body / rng),
|
|
"bullish": float(c) > float(o),
|
|
"ret_1": float((c - prev_c) / prev_c * 100) if prev_c else 0,
|
|
"dist_ema_pct": float((c - ema20[idx - 1]) / ema20[idx - 1] * 100) if ema20[idx - 1] else 0,
|
|
}
|
|
|
|
|
|
def trade_context(df: pd.DataFrame, t: Trade, rsi, atr, ema20) -> dict:
|
|
open_i = df.index.get_indexer([pd.Timestamp(t.open_time)], method="nearest")[0]
|
|
close_i = df.index.get_indexer([pd.Timestamp(t.close_time)], method="nearest")[0]
|
|
pre_start = max(1, open_i - CONTEXT_BARS)
|
|
pre_bars = []
|
|
for i in range(pre_start, open_i):
|
|
pre_bars.append(bar_features(df, i, rsi, atr, ema20))
|
|
|
|
hold_bars = []
|
|
for i in range(open_i, min(close_i + 1, len(df))):
|
|
hold_bars.append(bar_features(df, i, rsi, atr, ema20))
|
|
|
|
entry_f = bar_features(df, open_i, rsi, atr, ema20)
|
|
exit_f = bar_features(df, close_i, rsi, atr, ema20)
|
|
|
|
pre_rsi = [b["rsi"] for b in pre_bars if not np.isnan(b.get("rsi", np.nan))]
|
|
hold_rsi = [b["rsi"] for b in hold_bars if not np.isnan(b.get("rsi", np.nan))]
|
|
|
|
adverse_move = 0.0
|
|
if t.side == "BUY" and hold_bars:
|
|
adverse_move = float(t.open_price) - min(b["close"] for b in hold_bars)
|
|
elif t.side == "SELL" and hold_bars:
|
|
adverse_move = max(b["close"] for b in hold_bars) - float(t.open_price)
|
|
|
|
return {
|
|
"side": t.side,
|
|
"exit_reason": t.exit_reason,
|
|
"profit": t.profit,
|
|
"bars_held": t.bars_held,
|
|
"open_time": str(t.open_time),
|
|
"close_time": str(t.close_time),
|
|
"entry_rsi": entry_f.get("rsi"),
|
|
"exit_rsi": exit_f.get("rsi"),
|
|
"rsi_min_hold": min(hold_rsi) if hold_rsi else None,
|
|
"rsi_max_hold": max(hold_rsi) if hold_rsi else None,
|
|
"rsi_trend_pre": (pre_rsi[-1] - pre_rsi[0]) if len(pre_rsi) >= 2 else 0,
|
|
"adverse_pts": adverse_move,
|
|
"adverse_atr": adverse_move / entry_f["atr"] if entry_f.get("atr") else 0,
|
|
"entry_hour": pd.Timestamp(t.open_time).hour,
|
|
"entry_dist_ema_pct": entry_f.get("dist_ema_pct", 0),
|
|
"pre_bullish_ratio": sum(1 for b in pre_bars if b.get("bullish")) / max(len(pre_bars), 1),
|
|
"entry_body_pct": entry_f.get("body_pct", 0),
|
|
}
|
|
|
|
|
|
def gap_analysis(losers: list[dict]) -> dict:
|
|
if len(losers) < 2:
|
|
return {}
|
|
times = sorted(pd.Timestamp(t["close_time"]) for t in losers)
|
|
gaps_h = [(times[i] - times[i - 1]).total_seconds() / 3600 for i in range(1, len(times))]
|
|
return {
|
|
"median_gap_hours": float(np.median(gaps_h)),
|
|
"pct_gap_under_4h": float(sum(1 for g in gaps_h if g < 4) / len(gaps_h) * 100),
|
|
"pct_gap_under_24h": float(sum(1 for g in gaps_h if g < 24) / len(gaps_h) * 100),
|
|
"clustered": float(sum(1 for g in gaps_h if g < 2) / len(gaps_h) * 100),
|
|
}
|
|
|
|
|
|
def trader_narrative(sid: str, engine: str, losers_ctx: list[dict], winners_ctx: list[dict], by_reason: dict) -> list[str]:
|
|
notes: list[str] = []
|
|
if not losers_ctx:
|
|
return ["No losing trades to analyze."]
|
|
|
|
top_reason = max(by_reason.items(), key=lambda x: x[1]["count"])[0]
|
|
lr = [c for c in losers_ctx if c["exit_reason"] == top_reason]
|
|
wr = winners_ctx
|
|
|
|
if top_reason == "rsi_against":
|
|
sell_l = [c for c in lr if c["side"] == "SELL"]
|
|
buy_l = [c for c in lr if c["side"] == "BUY"]
|
|
if sell_l:
|
|
avg_adv = np.mean([c["adverse_atr"] for c in sell_l if c["adverse_atr"]])
|
|
notes.append(
|
|
f"SELL rsi_against ({len(sell_l)}): price ripped up avg {avg_adv:.1f} ATR after shorting "
|
|
f"overbought fade — classic short squeeze / momentum continuation, not mean reversion."
|
|
)
|
|
late_h = sum(1 for c in sell_l if c["entry_hour"] >= 18) / len(sell_l) * 100
|
|
if late_h > 30:
|
|
notes.append(f"{late_h:.0f}% of losing shorts after 18:00 — avoid fading strength into close.")
|
|
if buy_l:
|
|
notes.append(
|
|
f"BUY rsi_against ({len(buy_l)}): dipped deeper after oversold entry — "
|
|
f"knife-catching; need deeper OS threshold or wait for RSI curl-up."
|
|
)
|
|
if wr:
|
|
w_sell = [c for c in wr if c["side"] == "SELL"]
|
|
if w_sell and sell_l:
|
|
w_rsi = np.mean([c["entry_rsi"] for c in w_sell])
|
|
l_rsi = np.mean([c["entry_rsi"] for c in sell_l])
|
|
notes.append(f"Winning shorts entered RSI~{w_rsi:.0f} vs losers~{l_rsi:.0f} — losers entered too early in OB zone.")
|
|
|
|
elif top_reason == "sl":
|
|
notes.append("SL hits: stops inside noise — widen SL to 1.5-2x ATR or reduce lot.")
|
|
avg_atr = np.mean([c["adverse_atr"] for c in lr if c.get("adverse_atr")])
|
|
notes.append(f"Avg adverse move before SL = {avg_atr:.1f} ATR — box breakout often retests.")
|
|
|
|
elif top_reason == "adverse_atr":
|
|
notes.append("ATR stop hits: entries fighting trend — fade only when RSI extreme + session filter; widen stop or skip gap-down buys.")
|
|
buy_l = [c for c in lr if c["side"] == "BUY"]
|
|
if buy_l:
|
|
late = sum(1 for c in buy_l if c["entry_hour"] >= 20) / len(buy_l) * 100
|
|
if late > 25:
|
|
notes.append(f"{late:.0f}% of stopped-out buys after 20:00 — overnight gap risk on equities.")
|
|
sell_l = [c for c in lr if c["side"] == "SELL"]
|
|
if sell_l:
|
|
avg_adv = np.mean([c["adverse_atr"] for c in sell_l if c.get("adverse_atr")])
|
|
notes.append(f"Short stops avg {avg_adv:.1f} ATR adverse — momentum continuation, not reversion.")
|
|
|
|
elif top_reason == "trail":
|
|
notes.append("Trail exits: winners cut early in chop — widen trail_distance or raise activation.")
|
|
|
|
elif top_reason == "trend_strong":
|
|
notes.append("trend_strong: exited into momentum — filter only blocks entries, don't force-close in profit.")
|
|
|
|
gaps = gap_analysis(losers_ctx)
|
|
if gaps.get("clustered", 0) > 25:
|
|
notes.append(
|
|
f"{gaps['clustered']:.0f}% of losses within 2h of prior loss — regime chop; "
|
|
f"add cooldown after loss or skip when ATR expanding."
|
|
)
|
|
|
|
return notes
|
|
|
|
|
|
def suggest_params(engine: str, losers_ctx: list[dict], params: dict) -> dict:
|
|
sug = {}
|
|
if engine == "rsi_scalp":
|
|
sell_l = [c for c in losers_ctx if c["side"] == "SELL" and c["exit_reason"] == "rsi_against"]
|
|
if sell_l and np.mean([c["adverse_atr"] for c in sell_l]) > 1.5:
|
|
sug["rsi_overbought"] = min(75, params.get("rsi_overbought", 70) + 5)
|
|
sug["bars_to_wait"] = min(12, params.get("bars_to_wait", 5) + 3)
|
|
sug["trail_distance_pts"] = params.get("trail_distance_pts", 50) * 1.4
|
|
sug["skip_short_hour_after"] = 17
|
|
buy_l = [c for c in losers_ctx if c["side"] == "BUY" and c["exit_reason"] == "rsi_against"]
|
|
if buy_l:
|
|
sug["rsi_oversold"] = max(20, params.get("rsi_oversold", 30) - 5)
|
|
elif engine == "darvas":
|
|
sug["stop_loss_pts"] = int(params.get("stop_loss_pts", 300) * 1.35)
|
|
sug["require_retest"] = True
|
|
return sug
|
|
|
|
|
|
def analyze(sid: str) -> dict:
|
|
spec = find_spec(sid)
|
|
start, end = PERIODS["2021-2026"]
|
|
df, trades, params = run_backtest(spec, start, end)
|
|
|
|
rsi = calculate_rsi(df["close"], int(params.get("rsi_period", 14))).to_numpy()
|
|
atr = calculate_atr(df, 14).to_numpy()
|
|
ema20 = calculate_ema(df["close"], 20).to_numpy()
|
|
|
|
winners = [t for t in trades if t.profit >= 0]
|
|
losers = [t for t in trades if t.profit < 0]
|
|
|
|
losers_ctx = [trade_context(df, t, rsi, atr, ema20) for t in losers]
|
|
winners_ctx = [trade_context(df, t, rsi, atr, ema20) for t in winners[:200]]
|
|
|
|
by_reason: dict = {}
|
|
for c in losers_ctx:
|
|
r = c["exit_reason"]
|
|
bucket = by_reason.setdefault(r, {"count": 0, "pnl": 0.0, "ctx": []})
|
|
bucket["count"] += 1
|
|
bucket["pnl"] += c["profit"]
|
|
bucket["ctx"].append(c)
|
|
|
|
narrative = trader_narrative(sid, spec["engine"], losers_ctx, winners_ctx, by_reason)
|
|
suggestions = suggest_params(spec["engine"], losers_ctx, params)
|
|
|
|
result = {
|
|
"strategy_id": sid,
|
|
"symbol": spec["symbol"],
|
|
"engine": spec["engine"],
|
|
"total_trades": len(trades),
|
|
"losers": len(losers),
|
|
"winners": len(winners),
|
|
"loss_by_reason": {k: {"count": v["count"], "pnl": round(v["pnl"], 2)} for k, v in by_reason.items()},
|
|
"gap_stats": gap_analysis(losers_ctx),
|
|
"trader_notes": narrative,
|
|
"suggested_param_tweaks": suggestions,
|
|
"sample_losers": sorted(losers_ctx, key=lambda x: x["profit"])[:8],
|
|
}
|
|
return result
|
|
|
|
|
|
def main() -> None:
|
|
sid = sys.argv[1] if len(sys.argv) > 1 else "united_rsi_scalp_appl"
|
|
if not mt5.initialize():
|
|
raise SystemExit(f"MT5 init failed: {mt5.last_error()}")
|
|
try:
|
|
result = analyze(sid)
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = OUT_DIR / f"{sid}_loss_context.json"
|
|
path.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
print(f"Wrote {path}\n")
|
|
print(f"=== {sid} loss context ({result['losers']} losers / {result['total_trades']} trades) ===\n")
|
|
for reason, stats in sorted(result["loss_by_reason"].items(), key=lambda x: x[1]["pnl"]):
|
|
print(f" {reason:14} count={stats['count']:4} pnl=${stats['pnl']:,.0f}")
|
|
print("\nTrader read:")
|
|
for n in result["trader_notes"]:
|
|
print(f" - {n}")
|
|
if result["suggested_param_tweaks"]:
|
|
print("\nSuggested tweaks:", result["suggested_param_tweaks"])
|
|
finally:
|
|
mt5.shutdown()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|