mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-03 22:07:44 +00:00
Phase 1 complete: S3-S6 strategies, S4 variant analysis, learnings doc
- S3 Key Level Breakout: best performer (52-53% WR, PF ~1.0 on JPY crosses) - S4 EMA Ribbon: tested 7 variants (D/E/F/F-v2/G/G-Minimal), exhausted - Only EUR_AUD S4-F marginally profitable (PF 1.06) - Detailed filter funnel analysis revealed contradictory filter stacking - S5 Momentum Exhaustion: extended to 5 pairs, PF 0.43-0.77 - S6 EMA Bounce: 59-60% WR but PF 0.83-0.84, needs SL/TP restructuring - Added STRATEGY_LEARNINGS.md with design principles and next steps - Added M5 data downloader for 3-timeframe strategies - Updated README with full strategy scorecard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -20,9 +20,12 @@ from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
from src.strategies_pkg import (
|
||||
STRATEGIES, STRATEGY_PAIRS, STRATEGY_TIMEFRAMES,
|
||||
S1_MA_Breakout, S2_VWAP_Reversal, S3_KeyLevel_Breakout,
|
||||
S1_MA_Breakout, S3_KeyLevel_Breakout,
|
||||
S4_EMA_Ribbon, S5_Momentum_Exhaustion,
|
||||
S6_EMA_Bounce,
|
||||
)
|
||||
from src.trade_pdf_report import generate_trade_pdf
|
||||
# S2_VWAP_Reversal disabled — needs full redesign
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1")
|
||||
@@ -87,7 +90,7 @@ def run_strategy_backtest(strategy_id: int, pair: str):
|
||||
# Get trade log
|
||||
trade_log = bt.get_trade_log_df()
|
||||
|
||||
return report, trade_log
|
||||
return report, trade_log, data, htf_data
|
||||
|
||||
|
||||
def save_results(strategy_id: int, strategy_name: str, pair: str,
|
||||
@@ -168,10 +171,15 @@ def main():
|
||||
print(f" SKIPPED (data not available)")
|
||||
continue
|
||||
|
||||
report, trade_log = result
|
||||
report, trade_log, data, htf_data = result
|
||||
save_results(strategy_id, strat_name, pair, report, trade_log)
|
||||
print_report_summary(report)
|
||||
|
||||
# Generate per-trade PDF report
|
||||
if len(trade_log) > 0:
|
||||
generate_trade_pdf(strategy_id, pair, report, trade_log,
|
||||
data, htf_data)
|
||||
|
||||
total_trades = report.get("total_trades", 0)
|
||||
strategy_trades_total += total_trades
|
||||
|
||||
|
||||
+30
-11
@@ -68,6 +68,7 @@ class Position:
|
||||
confluence_score: int = 0
|
||||
signal_features: dict = field(default_factory=dict)
|
||||
realized_pnl: float = 0.0 # Tracks PnL from partial closes
|
||||
no_breakeven: bool = False # When True, don't move SL to breakeven after TP1
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -99,6 +100,7 @@ class TradeRecord:
|
||||
candle_body_ratio: float = 0.0
|
||||
hour_of_day: int = 0
|
||||
day_of_week: int = 0
|
||||
entry_pattern: str = ""
|
||||
exit_price: float = 0.0
|
||||
exit_reason: str = ""
|
||||
exit_time: pd.Timestamp = None
|
||||
@@ -168,12 +170,12 @@ class Backtester:
|
||||
return False
|
||||
|
||||
def _calculate_position_size(self, sl_distance: float,
|
||||
confluence_score: int) -> float:
|
||||
"""Fixed 1% risk position sizing."""
|
||||
confluence_score: int,
|
||||
risk_pct: float = 0.01) -> float:
|
||||
"""Risk-based position sizing (default 1%, strategies can override)."""
|
||||
if sl_distance <= 0:
|
||||
return 0.0
|
||||
|
||||
risk_pct = 0.01 # Flat 1% risk for consistency
|
||||
risk_amount = self.equity * risk_pct
|
||||
# Position size = risk_amount / SL distance in price
|
||||
position_size = risk_amount / sl_distance
|
||||
@@ -238,14 +240,19 @@ class Backtester:
|
||||
close_size = pos.initial_size * pos.tp_splits[0]
|
||||
self._partial_close(pos, pos.tp1_price, close_size, "TP1", candle)
|
||||
pos.tp1_hit = True
|
||||
# Move SL to breakeven after TP1
|
||||
pos.trailing_sl = pos.entry_price
|
||||
if not pos.no_breakeven:
|
||||
# Move SL to breakeven after TP1
|
||||
pos.trailing_sl = pos.entry_price
|
||||
|
||||
# Check TP2
|
||||
if not pos.tp2_hit and pos.tp1_hit and high >= pos.tp2_price:
|
||||
close_size = pos.initial_size * pos.tp_splits[1]
|
||||
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
||||
if close_size > 0:
|
||||
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
||||
pos.tp2_hit = True
|
||||
# If no breakeven was set, start trailing from original SL
|
||||
if pos.trailing_sl is None:
|
||||
pos.trailing_sl = pos.sl_price
|
||||
|
||||
# Check TP3
|
||||
if not pos.tp3_hit and pos.tp2_hit and high >= pos.tp3_price:
|
||||
@@ -271,13 +278,17 @@ class Backtester:
|
||||
close_size = pos.initial_size * pos.tp_splits[0]
|
||||
self._partial_close(pos, pos.tp1_price, close_size, "TP1", candle)
|
||||
pos.tp1_hit = True
|
||||
pos.trailing_sl = pos.entry_price
|
||||
if not pos.no_breakeven:
|
||||
pos.trailing_sl = pos.entry_price
|
||||
|
||||
# Check TP2
|
||||
if not pos.tp2_hit and pos.tp1_hit and low <= pos.tp2_price:
|
||||
close_size = pos.initial_size * pos.tp_splits[1]
|
||||
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
||||
if close_size > 0:
|
||||
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
||||
pos.tp2_hit = True
|
||||
if pos.trailing_sl is None:
|
||||
pos.trailing_sl = pos.sl_price
|
||||
|
||||
# Check TP3
|
||||
if not pos.tp3_hit and pos.tp2_hit and low <= pos.tp3_price:
|
||||
@@ -391,6 +402,7 @@ class Backtester:
|
||||
candle_body_ratio=features.get("candle_body_ratio", 0),
|
||||
hour_of_day=features.get("hour_of_day", 0),
|
||||
day_of_week=features.get("day_of_week", 0),
|
||||
entry_pattern=features.get("entry_pattern", ""),
|
||||
exit_price=exit_price,
|
||||
exit_reason=exit_detail,
|
||||
exit_time=exit_time,
|
||||
@@ -433,6 +445,9 @@ class Backtester:
|
||||
|
||||
def run(self) -> dict:
|
||||
"""Run the backtest. Returns performance report dict."""
|
||||
# Give the strategy access to full HTF data (strategy filters by timestamp)
|
||||
self.strategy.htf_data = self.htf_data
|
||||
|
||||
# Need at least 200 bars for indicators to warm up
|
||||
warmup = 200
|
||||
|
||||
@@ -479,12 +494,14 @@ class Backtester:
|
||||
tp_splits = signal.get("tp_splits", (0.40, 0.40, 0.20))
|
||||
trail_mult = signal.get("trail_atr_mult", 1.5)
|
||||
max_bars = signal.get("max_bars", 200)
|
||||
no_breakeven = signal.get("no_breakeven", False)
|
||||
risk_pct = signal.get("risk_pct", 0.01)
|
||||
|
||||
# Minimum 1.5:1 RR check (TP1 vs SL distance)
|
||||
# Minimum RR check (TP1 vs SL distance)
|
||||
entry = candle["close"]
|
||||
sl_dist = abs(entry - sl)
|
||||
tp1_dist = abs(tp1 - entry)
|
||||
if sl_dist == 0 or tp1_dist / sl_dist < 1.5:
|
||||
if sl_dist == 0 or tp1_dist / sl_dist < 0.5:
|
||||
continue
|
||||
|
||||
# Apply spread and slippage to entry
|
||||
@@ -498,12 +515,13 @@ class Backtester:
|
||||
continue
|
||||
|
||||
# Position sizing
|
||||
size = self._calculate_position_size(sl_dist_adj, confluence)
|
||||
size = self._calculate_position_size(sl_dist_adj, confluence, risk_pct)
|
||||
if size <= 0:
|
||||
continue
|
||||
|
||||
# Build features for logging
|
||||
features = self._build_signal_features(candle, i)
|
||||
features["entry_pattern"] = signal.get("entry_pattern", "")
|
||||
|
||||
# Open position
|
||||
pos = Position(
|
||||
@@ -522,6 +540,7 @@ class Backtester:
|
||||
strategy_id=self.strategy.strategy_id,
|
||||
confluence_score=confluence,
|
||||
signal_features=features,
|
||||
no_breakeven=no_breakeven,
|
||||
)
|
||||
self.open_positions.append(pos)
|
||||
|
||||
|
||||
@@ -290,6 +290,143 @@ def killswitch():
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/backtest-chart")
|
||||
@requires_auth
|
||||
def backtest_chart():
|
||||
"""Backtest trade visualization chart — scans available trade CSVs."""
|
||||
results_dir = APP_ROOT / "results" / "phase1"
|
||||
combos = []
|
||||
if results_dir.exists():
|
||||
for f in sorted(results_dir.glob("*_trades.csv")):
|
||||
# e.g. S1_GBP_AUD_trades.csv -> strategy=S1, pair=GBP_AUD
|
||||
parts = f.stem.replace("_trades", "").split("_", 1)
|
||||
if len(parts) == 2:
|
||||
combos.append({"strategy": parts[0], "pair": parts[1],
|
||||
"label": f"{parts[0]} / {parts[1].replace('_', '/')}"})
|
||||
return render_template("backtest_chart.html", combos=combos)
|
||||
|
||||
|
||||
@app.route("/api/backtest-chart-data")
|
||||
@requires_auth
|
||||
def api_backtest_chart_data():
|
||||
"""Return OHLC + indicators + trades as JSON for the backtest chart."""
|
||||
strategy = request.args.get("strategy", "")
|
||||
pair = request.args.get("pair", "")
|
||||
timeframe = request.args.get("timeframe", "M15")
|
||||
start = request.args.get("start", "")
|
||||
end = request.args.get("end", "")
|
||||
|
||||
# Validate
|
||||
if not strategy or not pair:
|
||||
return jsonify({"error": "strategy and pair are required"}), 400
|
||||
|
||||
# Load OHLC
|
||||
ohlc_path = APP_ROOT / "data" / "processed" / f"{pair}_{timeframe}.csv"
|
||||
if not ohlc_path.exists():
|
||||
return jsonify({"error": f"OHLC file not found: {pair}_{timeframe}.csv"}), 404
|
||||
|
||||
df = pd.read_csv(ohlc_path, parse_dates=["timestamp"], index_col="timestamp")
|
||||
for col in ["open", "high", "low", "close"]:
|
||||
df[col] = df[col].astype(float)
|
||||
df["volume"] = pd.to_numeric(df.get("volume", 0), errors="coerce").fillna(0)
|
||||
|
||||
# Date filter
|
||||
if start:
|
||||
df = df[df.index >= pd.Timestamp(start, tz="UTC")]
|
||||
if end:
|
||||
df = df[df.index <= pd.Timestamp(end, tz="UTC")]
|
||||
|
||||
if df.empty:
|
||||
return jsonify({"error": "No OHLC data in selected range"}), 404
|
||||
|
||||
# Compute indicators
|
||||
from indicators.technical import compute_all_indicators, identify_key_levels
|
||||
df = compute_all_indicators(df)
|
||||
|
||||
# Build OHLC list (round to 5 decimals)
|
||||
time_strings = df.index.strftime("%Y-%m-%dT%H:%M:%S").tolist()
|
||||
ohlc_data = []
|
||||
for i, (idx, row) in enumerate(df.iterrows()):
|
||||
ohlc_data.append({
|
||||
"time": time_strings[i],
|
||||
"open": round(row["open"], 5),
|
||||
"high": round(row["high"], 5),
|
||||
"low": round(row["low"], 5),
|
||||
"close": round(row["close"], 5),
|
||||
})
|
||||
|
||||
# Build EMA indicator series
|
||||
indicators = {}
|
||||
for key in ["ema_50", "ema_100", "ema_200"]:
|
||||
if key in df.columns:
|
||||
series_data = []
|
||||
for i, (idx, row) in enumerate(df.iterrows()):
|
||||
val = row[key]
|
||||
if pd.notna(val):
|
||||
series_data.append({"time": time_strings[i], "value": round(float(val), 5)})
|
||||
indicators[key] = series_data
|
||||
|
||||
# Load trades
|
||||
trades_path = APP_ROOT / "results" / "phase1" / f"{strategy}_{pair}_trades.csv"
|
||||
trades = []
|
||||
if trades_path.exists():
|
||||
tdf = pd.read_csv(trades_path, parse_dates=["timestamp"])
|
||||
if "exit_time" in tdf.columns:
|
||||
tdf["exit_time"] = pd.to_datetime(tdf["exit_time"])
|
||||
# Apply date filter to trades
|
||||
if start:
|
||||
tdf = tdf[tdf["timestamp"] >= pd.Timestamp(start, tz="UTC")]
|
||||
if end:
|
||||
tdf = tdf[tdf["timestamp"] <= pd.Timestamp(end, tz="UTC")]
|
||||
|
||||
for _, trow in tdf.iterrows():
|
||||
trade = {
|
||||
"timestamp": trow["timestamp"].strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"direction": trow.get("signal_direction", ""),
|
||||
"entry_price": round(float(trow.get("entry_price", 0)), 5),
|
||||
"sl_price": round(float(trow.get("sl_price", 0)), 5),
|
||||
"tp1_price": round(float(trow.get("tp1_price", 0)), 5),
|
||||
"tp2_price": round(float(trow.get("tp2_price", 0)), 5),
|
||||
"tp3_price": round(float(trow.get("tp3_price", 0)), 5),
|
||||
"exit_price": round(float(trow.get("exit_price", 0)), 5),
|
||||
"exit_reason": str(trow.get("exit_reason", "")),
|
||||
"pnl_pips": round(float(trow.get("pnl_pips", 0)), 1),
|
||||
"pnl_dollars": round(float(trow.get("pnl_dollars", 0)), 2),
|
||||
"hold_time_minutes": int(trow.get("hold_time_minutes", 0)),
|
||||
"confluence_score": int(trow.get("confluence_score", 0)),
|
||||
"session": str(trow.get("session", "")),
|
||||
"win": bool(trow.get("win", False)),
|
||||
}
|
||||
if pd.notna(trow.get("exit_time")):
|
||||
trade["exit_time"] = trow["exit_time"].strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# Key S/R levels only for strategies that use them (S3, S5)
|
||||
trade["key_levels"] = []
|
||||
if strategy in ("S3", "S5"):
|
||||
entry_ts = trow["timestamp"]
|
||||
pre_entry = df[df.index <= entry_ts].tail(500)
|
||||
if len(pre_entry) >= 30:
|
||||
levels = identify_key_levels(pre_entry, min_touches=2)
|
||||
trade["key_levels"] = [
|
||||
{"price": round(float(p), 5), "touches": int(t)}
|
||||
for p, t in levels[:6]
|
||||
]
|
||||
|
||||
# For all strategies: include the EMA values at entry as reference
|
||||
entry_ts = trow["timestamp"]
|
||||
entry_row = df[df.index <= entry_ts].iloc[-1] if len(df[df.index <= entry_ts]) > 0 else None
|
||||
if entry_row is not None:
|
||||
trade["ema_at_entry"] = {
|
||||
"ema_50": round(float(entry_row.get("ema_50", 0)), 5),
|
||||
"ema_100": round(float(entry_row.get("ema_100", 0)), 5),
|
||||
"ema_200": round(float(entry_row.get("ema_200", 0)), 5),
|
||||
}
|
||||
|
||||
trades.append(trade)
|
||||
|
||||
return jsonify({"ohlc": ohlc_data, "indicators": indicators, "trades": trades})
|
||||
|
||||
|
||||
@app.route("/chart")
|
||||
@requires_auth
|
||||
def chart():
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Download M5 candle data from OANDA for S4-F-v2 testing.
|
||||
|
||||
Fetches 2022-07-01 to 2024-12-31 (extra warmup) for GBP_AUD, EUR_AUD, GBP_JPY.
|
||||
Saves raw OHLCV CSVs to data/processed/{PAIR}_M5.csv.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import requests
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from src.config_loader import load_config
|
||||
|
||||
# Load config + .env
|
||||
cfg = load_config()
|
||||
API_KEY = os.getenv("OANDA_API_KEY")
|
||||
ENV = os.getenv("OANDA_ENV", "practice")
|
||||
BASE = (
|
||||
"https://api-fxpractice.oanda.com"
|
||||
if ENV == "practice"
|
||||
else "https://api-fxtrade.oanda.com"
|
||||
)
|
||||
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
||||
|
||||
PAIRS = ["GBP_AUD", "EUR_AUD", "GBP_JPY"]
|
||||
GRANULARITY = "M5"
|
||||
START_DATE = datetime(2022, 7, 1, tzinfo=timezone.utc)
|
||||
END_DATE = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
|
||||
|
||||
def fetch_chunk(instrument, from_dt, to_dt):
|
||||
"""Fetch candles between two datetimes."""
|
||||
params = {
|
||||
"granularity": GRANULARITY,
|
||||
"from": from_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"to": to_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"price": "M", # mid prices
|
||||
}
|
||||
url = f"{BASE}/v3/instruments/{instrument}/candles"
|
||||
r = requests.get(url, headers=HEADERS, params=params)
|
||||
r.raise_for_status()
|
||||
return r.json().get("candles", [])
|
||||
|
||||
|
||||
def candles_to_df(candles):
|
||||
"""Convert OANDA candle list to DataFrame."""
|
||||
rows = []
|
||||
for c in candles:
|
||||
if not c.get("complete", True):
|
||||
continue
|
||||
mid = c["mid"]
|
||||
rows.append({
|
||||
"time": c["time"],
|
||||
"open": float(mid["o"]),
|
||||
"high": float(mid["h"]),
|
||||
"low": float(mid["l"]),
|
||||
"close": float(mid["c"]),
|
||||
"volume": int(c.get("volume", 0)),
|
||||
})
|
||||
df = pd.DataFrame(rows)
|
||||
if df.empty:
|
||||
return df
|
||||
df["time"] = pd.to_datetime(df["time"])
|
||||
df = df.set_index("time").sort_index()
|
||||
return df
|
||||
|
||||
|
||||
def download_pair(pair):
|
||||
"""Download all M5 data for a pair in paginated chunks."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Downloading {pair} {GRANULARITY}")
|
||||
print(f"Range: {START_DATE.date()} -> {END_DATE.date()}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
chunk_duration = timedelta(minutes=5 * 4999) # ~4999 candles per chunk
|
||||
all_candles = []
|
||||
cursor = START_DATE
|
||||
chunk_num = 0
|
||||
|
||||
while cursor < END_DATE:
|
||||
chunk_end = min(cursor + chunk_duration, END_DATE)
|
||||
chunk_num += 1
|
||||
print(f" Chunk {chunk_num}: {cursor.strftime('%Y-%m-%d %H:%M')} -> "
|
||||
f"{chunk_end.strftime('%Y-%m-%d %H:%M')} ... ", end="", flush=True)
|
||||
|
||||
try:
|
||||
candles = fetch_chunk(pair, cursor, chunk_end)
|
||||
print(f"{len(candles)} candles")
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
candles = []
|
||||
|
||||
if candles:
|
||||
all_candles.extend(candles)
|
||||
last_time = pd.to_datetime(candles[-1]["time"])
|
||||
cursor = last_time.to_pydatetime().replace(tzinfo=timezone.utc) + timedelta(minutes=5)
|
||||
else:
|
||||
cursor = chunk_end
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
print(f" Total raw candles: {len(all_candles)}")
|
||||
|
||||
if not all_candles:
|
||||
print(f" NO DATA for {pair}!")
|
||||
return None
|
||||
|
||||
df = candles_to_df(all_candles)
|
||||
df = df[~df.index.duplicated(keep="last")]
|
||||
df = df.sort_index()
|
||||
print(f" After dedup: {len(df)} candles")
|
||||
print(f" Range: {df.index[0]} -> {df.index[-1]}")
|
||||
|
||||
# Save
|
||||
os.makedirs(PROCESSED_DIR, exist_ok=True)
|
||||
out_path = os.path.join(PROCESSED_DIR, f"{pair}_M5.csv")
|
||||
df.to_csv(out_path)
|
||||
print(f" Saved: {out_path}")
|
||||
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not API_KEY:
|
||||
print("ERROR: OANDA_API_KEY not set. Check config/.env")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"OANDA API: {BASE}")
|
||||
print(f"API Key: {API_KEY[:8]}...{API_KEY[-4:]}")
|
||||
|
||||
for pair in PAIRS:
|
||||
download_pair(pair)
|
||||
|
||||
print("\nDone! M5 data downloaded.")
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Generate PDF trade charts for S6A with 200 bars pre-entry context."""
|
||||
import gc, os, sys
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import mplfinance as mpf
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.lines import Line2D
|
||||
from matplotlib.backends.backend_pdf import PdfPages
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1")
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
|
||||
DARK_BG = "#1e1e1e"
|
||||
DARK_GRID = "#2a2a2a"
|
||||
TEXT_COLOR = "white"
|
||||
TEXT_DIM = "#aaaaaa"
|
||||
GREEN = "#00cc66"
|
||||
RED = "#ee4444"
|
||||
CYAN = "#00bcd4"
|
||||
BLUE = "#4488ff"
|
||||
ORANGE = "#ff9900"
|
||||
MAGENTA = "#ff44ff"
|
||||
YELLOW = "#ffff00"
|
||||
|
||||
MC = mpf.make_marketcolors(up="green", down="red",
|
||||
edge={"up": "green", "down": "red"},
|
||||
wick={"up": "green", "down": "red"}, volume="in")
|
||||
MPF_STYLE = mpf.make_mpf_style(marketcolors=MC, gridstyle=":", gridcolor=DARK_GRID,
|
||||
facecolor=DARK_BG, figcolor=DARK_BG,
|
||||
rc={"axes.labelcolor": TEXT_COLOR, "xtick.color": TEXT_COLOR, "ytick.color": TEXT_COLOR})
|
||||
|
||||
PRE_ENTRY_BARS = 200
|
||||
POST_EXIT_BARS = 30
|
||||
|
||||
|
||||
def gen_trade_fig(trade_row, trade_num, total_trades, data, pair):
|
||||
entry_time = pd.Timestamp(trade_row["timestamp"])
|
||||
exit_time = pd.Timestamp(trade_row["exit_time"])
|
||||
direction = trade_row["signal_direction"]
|
||||
entry_price = trade_row["entry_price"]
|
||||
exit_price = trade_row["exit_price"]
|
||||
sl_price = trade_row["sl_price"]
|
||||
tp1_price = trade_row["tp1_price"]
|
||||
pnl_pips = trade_row["pnl_pips"]
|
||||
exit_reason = trade_row["exit_reason"]
|
||||
pattern = trade_row.get("entry_pattern", "")
|
||||
|
||||
entry_idx = data.index.get_indexer([entry_time], method="nearest")[0]
|
||||
exit_idx = data.index.get_indexer([exit_time], method="nearest")[0]
|
||||
|
||||
start_idx = max(0, entry_idx - PRE_ENTRY_BARS)
|
||||
end_idx = min(len(data) - 1, exit_idx + POST_EXIT_BARS)
|
||||
if end_idx - start_idx < 40:
|
||||
end_idx = min(len(data) - 1, start_idx + 40)
|
||||
|
||||
chart_data = data.iloc[start_idx:end_idx + 1].copy()
|
||||
if len(chart_data) < 3:
|
||||
return None
|
||||
|
||||
addplots = []
|
||||
|
||||
if "ema_50" in chart_data.columns:
|
||||
ema50 = chart_data["ema_50"]
|
||||
if ema50.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema50, color=CYAN, width=1.2, panel=0))
|
||||
|
||||
if "ema_100" in chart_data.columns:
|
||||
ema100 = chart_data["ema_100"]
|
||||
if ema100.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema100, color=ORANGE, width=1.5, panel=0))
|
||||
|
||||
if "ema_200" in chart_data.columns:
|
||||
ema200 = chart_data["ema_200"]
|
||||
if ema200.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema200, color=MAGENTA, width=1.0,
|
||||
linestyle="--", panel=0))
|
||||
|
||||
# Entry marker
|
||||
entry_markers = pd.Series(np.nan, index=chart_data.index)
|
||||
if entry_time in chart_data.index:
|
||||
entry_markers.at[entry_time] = entry_price
|
||||
elif entry_idx >= start_idx and entry_idx <= end_idx:
|
||||
entry_markers.iloc[entry_idx - start_idx] = entry_price
|
||||
|
||||
if entry_markers.notna().any():
|
||||
mc = "^" if direction == "LONG" else "v"
|
||||
mcol = GREEN if direction == "LONG" else RED
|
||||
addplots.append(mpf.make_addplot(entry_markers, type="scatter", marker=mc,
|
||||
markersize=140, color=mcol, edgecolors="white", linewidths=0.8, panel=0))
|
||||
|
||||
# Exit marker
|
||||
exit_markers = pd.Series(np.nan, index=chart_data.index)
|
||||
if exit_time in chart_data.index:
|
||||
exit_markers.at[exit_time] = exit_price
|
||||
elif exit_idx >= start_idx and exit_idx <= end_idx:
|
||||
exit_markers.iloc[exit_idx - start_idx] = exit_price
|
||||
|
||||
if exit_markers.notna().any():
|
||||
addplots.append(mpf.make_addplot(exit_markers, type="scatter", marker="X",
|
||||
markersize=120, color=BLUE, edgecolors="white", linewidths=0.8, panel=0))
|
||||
|
||||
# Volume panel
|
||||
if "volume" in chart_data.columns:
|
||||
vol = chart_data["volume"]
|
||||
if vol.notna().any() and vol.sum() > 0:
|
||||
addplots.append(mpf.make_addplot(vol, type="bar", panel=1,
|
||||
color=GREEN, width=0.7, ylabel="Volume", alpha=0.6))
|
||||
|
||||
# ADX panel
|
||||
if "adx_14" in chart_data.columns:
|
||||
adx = chart_data["adx_14"]
|
||||
if adx.notna().any():
|
||||
addplots.append(mpf.make_addplot(adx, panel=2, color=YELLOW,
|
||||
width=1.0, ylabel="ADX"))
|
||||
|
||||
pnl_sign = "+" if pnl_pips >= 0 else ""
|
||||
pat_label = pattern.replace("ema_bounce_", "").replace("_", " ").title() if pattern else ""
|
||||
adx_entry = trade_row.get("adx_at_entry", 0)
|
||||
title = (f"S6A Trade #{trade_num}/{total_trades} {direction} {pair} | {pat_label} | "
|
||||
f"{entry_time.strftime('%Y-%m-%d %H:%M')} | "
|
||||
f"{pnl_sign}{pnl_pips:.1f}p ({exit_reason}) | ADX: {adx_entry:.1f}")
|
||||
|
||||
try:
|
||||
fig, axes = mpf.plot(chart_data, type="candle", style=MPF_STYLE,
|
||||
addplot=addplots if addplots else None, volume=False,
|
||||
figsize=(22, 13), tight_layout=False, returnfig=True,
|
||||
panel_ratios=(6, 1.2, 1.2))
|
||||
except Exception as e:
|
||||
print(f" WARNING: Could not plot trade #{trade_num}: {e}")
|
||||
return None
|
||||
|
||||
ax = axes[0]
|
||||
ax.set_title(title, color=TEXT_COLOR, fontsize=12, fontweight="bold", pad=12)
|
||||
|
||||
xlim = ax.get_xlim()
|
||||
ax.hlines(y=sl_price, xmin=xlim[0], xmax=xlim[1], colors=RED,
|
||||
linestyles="dashed", linewidth=0.9, alpha=0.7)
|
||||
ax.hlines(y=tp1_price, xmin=xlim[0], xmax=xlim[1], colors=GREEN,
|
||||
linestyles="dashed", linewidth=0.8, alpha=0.8)
|
||||
|
||||
ax.text(xlim[1], sl_price, " SL (200 EMA)", color=RED, fontsize=7,
|
||||
va="center", fontweight="bold")
|
||||
ax.text(xlim[1], tp1_price, " TP1 (4 ATR)", color=GREEN, fontsize=7, va="center")
|
||||
|
||||
# ADX reference lines
|
||||
for a in axes:
|
||||
if hasattr(a, 'get_ylabel') and a.get_ylabel() == "ADX":
|
||||
a.axhline(y=20, color=TEXT_DIM, linewidth=0.5, linestyle="--", alpha=0.5)
|
||||
a.axhline(y=25, color=YELLOW, linewidth=0.3, linestyle=":", alpha=0.3)
|
||||
a.set_ylim(0, max(60, adx.max() * 1.1) if adx.notna().any() else 60)
|
||||
a.tick_params(colors=TEXT_DIM, labelsize=6)
|
||||
break
|
||||
|
||||
for a in axes:
|
||||
if hasattr(a, 'get_ylabel') and a.get_ylabel() == "Volume":
|
||||
a.tick_params(colors=TEXT_DIM, labelsize=6)
|
||||
break
|
||||
|
||||
legend_elements = [
|
||||
Line2D([0], [0], color=CYAN, lw=1.2, label="EMA 50"),
|
||||
Line2D([0], [0], color=ORANGE, lw=1.5, label="EMA 100"),
|
||||
Line2D([0], [0], color=MAGENTA, lw=1.0, linestyle="--", label="EMA 200"),
|
||||
Line2D([0], [0], marker="^" if direction == "LONG" else "v",
|
||||
color=GREEN if direction == "LONG" else RED, lw=0, markersize=8, label="Entry"),
|
||||
Line2D([0], [0], marker="X", color=BLUE, lw=0, markersize=8, label="Exit"),
|
||||
Line2D([0], [0], color=RED, lw=0.8, linestyle="dashed", label="SL"),
|
||||
Line2D([0], [0], color=GREEN, lw=0.8, linestyle="dashed", label="TP1"),
|
||||
]
|
||||
ax.legend(handles=legend_elements, loc="upper left", fontsize=7,
|
||||
facecolor=DARK_GRID, edgecolor="#444444", labelcolor=TEXT_COLOR)
|
||||
|
||||
pnl_dollars = trade_row.get("pnl_dollars", 0)
|
||||
hold = trade_row.get("hold_time_minutes", 0)
|
||||
rsi_entry = trade_row.get("rsi_at_entry", 0)
|
||||
lot_size = trade_row.get("lot_size", 0)
|
||||
info = (f"Entry: {entry_price:.5f} Exit: {exit_price:.5f} "
|
||||
f"SL: {sl_price:.5f} TP1: {tp1_price:.5f} "
|
||||
f"PnL: {pnl_sign}{pnl_pips:.1f}p (${pnl_dollars:,.2f}) "
|
||||
f"Hold: {hold}min RSI: {rsi_entry:.1f} ADX: {adx_entry:.1f} "
|
||||
f"Lots: {lot_size:,.0f} Pattern: {pat_label}")
|
||||
|
||||
fig.text(0.05, 0.01, info, color=TEXT_DIM, fontsize=7.5, fontfamily="monospace",
|
||||
va="bottom", bbox=dict(boxstyle="round,pad=0.4", facecolor=DARK_GRID,
|
||||
edgecolor="#444444", alpha=0.9))
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def main():
|
||||
pair = "GBP_AUD"
|
||||
csv_path = os.path.join(RESULTS_DIR, "S6A_GBP_AUD_trades.csv")
|
||||
trade_log = pd.read_csv(csv_path)
|
||||
total = len(trade_log)
|
||||
print(f"Loaded {total} trades from {csv_path}")
|
||||
|
||||
data = pd.read_csv(os.path.join(PROCESSED_DIR, f"{pair}_M15.csv"),
|
||||
index_col=0, parse_dates=True)
|
||||
data.index.name = "timestamp"
|
||||
data = compute_all_indicators(data)
|
||||
print(f"Loaded {len(data)} M15 bars")
|
||||
|
||||
pdf_path = os.path.join(RESULTS_DIR, "S6A_GBP_AUD_trades.pdf")
|
||||
|
||||
with PdfPages(pdf_path) as pdf:
|
||||
for i in range(total):
|
||||
row = trade_log.iloc[i]
|
||||
fig = gen_trade_fig(row, i + 1, total, data, pair)
|
||||
if fig:
|
||||
pdf.savefig(fig, facecolor=DARK_BG, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
gc.collect()
|
||||
print(f" [{i+1}/{total}] added to PDF")
|
||||
|
||||
print(f"\nDone. PDF saved: {pdf_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Generate PNG trade charts for visual review.
|
||||
Shows EMA 50/100/200, Volume, ADX, with 100 bars of pre-entry context."""
|
||||
import gc, os, sys
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import mplfinance as mpf
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.lines import Line2D
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1")
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
|
||||
DARK_BG = "#1e1e1e"
|
||||
DARK_GRID = "#2a2a2a"
|
||||
TEXT_COLOR = "white"
|
||||
TEXT_DIM = "#aaaaaa"
|
||||
GREEN = "#00cc66"
|
||||
RED = "#ee4444"
|
||||
CYAN = "#00bcd4"
|
||||
BLUE = "#4488ff"
|
||||
ORANGE = "#ff9900"
|
||||
MAGENTA = "#ff44ff"
|
||||
YELLOW = "#ffff00"
|
||||
|
||||
MC = mpf.make_marketcolors(up="green", down="red",
|
||||
edge={"up": "green", "down": "red"},
|
||||
wick={"up": "green", "down": "red"}, volume="in")
|
||||
MPF_STYLE = mpf.make_mpf_style(marketcolors=MC, gridstyle=":", gridcolor=DARK_GRID,
|
||||
facecolor=DARK_BG, figcolor=DARK_BG,
|
||||
rc={"axes.labelcolor": TEXT_COLOR, "xtick.color": TEXT_COLOR, "ytick.color": TEXT_COLOR})
|
||||
|
||||
|
||||
def gen_trade_png(trade_row, trade_num, total_trades, data, pair, out_dir):
|
||||
entry_time = pd.Timestamp(trade_row["timestamp"])
|
||||
exit_time = pd.Timestamp(trade_row["exit_time"])
|
||||
direction = trade_row["signal_direction"]
|
||||
entry_price = trade_row["entry_price"]
|
||||
exit_price = trade_row["exit_price"]
|
||||
sl_price = trade_row["sl_price"]
|
||||
tp1_price = trade_row["tp1_price"]
|
||||
pnl_pips = trade_row["pnl_pips"]
|
||||
exit_reason = trade_row["exit_reason"]
|
||||
pattern = trade_row.get("entry_pattern", "")
|
||||
|
||||
entry_idx = data.index.get_indexer([entry_time], method="nearest")[0]
|
||||
exit_idx = data.index.get_indexer([exit_time], method="nearest")[0]
|
||||
|
||||
# 100 bars before entry, 20 bars after exit
|
||||
start_idx = max(0, entry_idx - 100)
|
||||
end_idx = min(len(data) - 1, exit_idx + 20)
|
||||
if end_idx - start_idx < 40:
|
||||
end_idx = min(len(data) - 1, start_idx + 40)
|
||||
|
||||
chart_data = data.iloc[start_idx:end_idx + 1].copy()
|
||||
if len(chart_data) < 3:
|
||||
return None
|
||||
|
||||
addplots = []
|
||||
|
||||
# EMA 50 overlay
|
||||
if "ema_50" in chart_data.columns:
|
||||
ema50 = chart_data["ema_50"]
|
||||
if ema50.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema50, color=CYAN, width=1.2, panel=0))
|
||||
|
||||
# EMA 100 overlay
|
||||
if "ema_100" in chart_data.columns:
|
||||
ema100 = chart_data["ema_100"]
|
||||
if ema100.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema100, color=ORANGE, width=1.5, panel=0))
|
||||
|
||||
# EMA 200 overlay
|
||||
if "ema_200" in chart_data.columns:
|
||||
ema200 = chart_data["ema_200"]
|
||||
if ema200.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema200, color=MAGENTA, width=1.0,
|
||||
linestyle="--", panel=0))
|
||||
|
||||
# Entry marker
|
||||
entry_markers = pd.Series(np.nan, index=chart_data.index)
|
||||
if entry_time in chart_data.index:
|
||||
entry_markers.at[entry_time] = entry_price
|
||||
elif entry_idx >= start_idx and entry_idx <= end_idx:
|
||||
entry_markers.iloc[entry_idx - start_idx] = entry_price
|
||||
|
||||
if entry_markers.notna().any():
|
||||
mc = "^" if direction == "LONG" else "v"
|
||||
mcol = GREEN if direction == "LONG" else RED
|
||||
addplots.append(mpf.make_addplot(entry_markers, type="scatter", marker=mc,
|
||||
markersize=140, color=mcol, edgecolors="white", linewidths=0.8, panel=0))
|
||||
|
||||
# Exit marker
|
||||
exit_markers = pd.Series(np.nan, index=chart_data.index)
|
||||
if exit_time in chart_data.index:
|
||||
exit_markers.at[exit_time] = exit_price
|
||||
elif exit_idx >= start_idx and exit_idx <= end_idx:
|
||||
exit_markers.iloc[exit_idx - start_idx] = exit_price
|
||||
|
||||
if exit_markers.notna().any():
|
||||
addplots.append(mpf.make_addplot(exit_markers, type="scatter", marker="X",
|
||||
markersize=120, color=BLUE, edgecolors="white", linewidths=0.8, panel=0))
|
||||
|
||||
# Volume panel (panel 1)
|
||||
if "volume" in chart_data.columns:
|
||||
vol = chart_data["volume"]
|
||||
if vol.notna().any() and vol.sum() > 0:
|
||||
# Color volume bars green/red based on candle direction
|
||||
vol_colors = pd.Series(GREEN, index=chart_data.index)
|
||||
vol_colors[chart_data["close"] < chart_data["open"]] = RED
|
||||
addplots.append(mpf.make_addplot(vol, type="bar", panel=1,
|
||||
color=GREEN, width=0.7, ylabel="Volume", alpha=0.6))
|
||||
|
||||
# ADX panel (panel 2)
|
||||
if "adx_14" in chart_data.columns:
|
||||
adx = chart_data["adx_14"]
|
||||
if adx.notna().any():
|
||||
addplots.append(mpf.make_addplot(adx, panel=2, color=YELLOW,
|
||||
width=1.0, ylabel="ADX"))
|
||||
|
||||
pnl_sign = "+" if pnl_pips >= 0 else ""
|
||||
pat_label = pattern.replace("ema_bounce_", "").replace("_", " ").title() if pattern else ""
|
||||
adx_entry = trade_row.get("adx_at_entry", 0)
|
||||
title = (f"Trade #{trade_num} {direction} {pair} | {pat_label} | "
|
||||
f"{entry_time.strftime('%Y-%m-%d %H:%M')} | "
|
||||
f"{pnl_sign}{pnl_pips:.1f}p ({exit_reason}) | ADX: {adx_entry:.1f}")
|
||||
|
||||
try:
|
||||
fig, axes = mpf.plot(chart_data, type="candle", style=MPF_STYLE,
|
||||
addplot=addplots if addplots else None, volume=False,
|
||||
figsize=(18, 11), tight_layout=False, returnfig=True,
|
||||
panel_ratios=(6, 1.2, 1.2))
|
||||
except Exception as e:
|
||||
print(f" WARNING: Could not plot trade #{trade_num}: {e}")
|
||||
return None
|
||||
|
||||
ax = axes[0]
|
||||
ax.set_title(title, color=TEXT_COLOR, fontsize=12, fontweight="bold", pad=12)
|
||||
|
||||
xlim = ax.get_xlim()
|
||||
ax.hlines(y=sl_price, xmin=xlim[0], xmax=xlim[1], colors=RED,
|
||||
linestyles="dashed", linewidth=0.9, alpha=0.7)
|
||||
ax.hlines(y=tp1_price, xmin=xlim[0], xmax=xlim[1], colors=GREEN,
|
||||
linestyles="dashed", linewidth=0.8, alpha=0.8)
|
||||
|
||||
ax.text(xlim[1], sl_price, " SL (200 EMA)", color=RED, fontsize=7,
|
||||
va="center", fontweight="bold")
|
||||
ax.text(xlim[1], tp1_price, " TP1 (4 ATR)", color=GREEN, fontsize=7, va="center")
|
||||
|
||||
# ADX reference lines
|
||||
for a in axes:
|
||||
if hasattr(a, 'get_ylabel') and a.get_ylabel() == "ADX":
|
||||
a.axhline(y=20, color=TEXT_DIM, linewidth=0.5, linestyle="--", alpha=0.5)
|
||||
a.axhline(y=25, color=YELLOW, linewidth=0.3, linestyle=":", alpha=0.3)
|
||||
a.set_ylim(0, max(60, adx.max() * 1.1) if adx.notna().any() else 60)
|
||||
a.tick_params(colors=TEXT_DIM, labelsize=6)
|
||||
break
|
||||
|
||||
# Volume axis styling
|
||||
for a in axes:
|
||||
if hasattr(a, 'get_ylabel') and a.get_ylabel() == "Volume":
|
||||
a.tick_params(colors=TEXT_DIM, labelsize=6)
|
||||
break
|
||||
|
||||
# Legend
|
||||
legend_elements = [
|
||||
Line2D([0], [0], color=CYAN, lw=1.2, label="EMA 50"),
|
||||
Line2D([0], [0], color=ORANGE, lw=1.5, label="EMA 100"),
|
||||
Line2D([0], [0], color=MAGENTA, lw=1.0, linestyle="--", label="EMA 200"),
|
||||
Line2D([0], [0], marker="^" if direction == "LONG" else "v",
|
||||
color=GREEN if direction == "LONG" else RED, lw=0, markersize=8, label="Entry"),
|
||||
Line2D([0], [0], marker="X", color=BLUE, lw=0, markersize=8, label="Exit"),
|
||||
Line2D([0], [0], color=RED, lw=0.8, linestyle="dashed", label="SL"),
|
||||
Line2D([0], [0], color=GREEN, lw=0.8, linestyle="dashed", label="TP1"),
|
||||
]
|
||||
ax.legend(handles=legend_elements, loc="upper left", fontsize=7,
|
||||
facecolor=DARK_GRID, edgecolor="#444444", labelcolor=TEXT_COLOR)
|
||||
|
||||
# Info box
|
||||
pnl_dollars = trade_row.get("pnl_dollars", 0)
|
||||
hold = trade_row.get("hold_time_minutes", 0)
|
||||
rsi_entry = trade_row.get("rsi_at_entry", 0)
|
||||
lot_size = trade_row.get("lot_size", 0)
|
||||
info = (f"Entry: {entry_price:.5f} Exit: {exit_price:.5f} "
|
||||
f"SL: {sl_price:.5f} TP1: {tp1_price:.5f} "
|
||||
f"PnL: {pnl_sign}{pnl_pips:.1f}p (${pnl_dollars:,.2f}) "
|
||||
f"Hold: {hold}min RSI: {rsi_entry:.1f} ADX: {adx_entry:.1f} "
|
||||
f"Lots: {lot_size:,.0f} Pattern: {pat_label}")
|
||||
|
||||
fig.text(0.05, 0.01, info, color=TEXT_DIM, fontsize=7.5, fontfamily="monospace",
|
||||
va="bottom", bbox=dict(boxstyle="round,pad=0.4", facecolor=DARK_GRID,
|
||||
edgecolor="#444444", alpha=0.9))
|
||||
|
||||
png_path = os.path.join(out_dir, f"S6_{pair}_trade{trade_num:02d}.png")
|
||||
fig.savefig(png_path, facecolor=DARK_BG, dpi=130, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
gc.collect()
|
||||
return png_path
|
||||
|
||||
|
||||
def main():
|
||||
strategy_id = 6
|
||||
pair = "GBP_AUD"
|
||||
n_trades = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
||||
|
||||
csv_path = os.path.join(RESULTS_DIR, f"S{strategy_id}_{pair}_trades.csv")
|
||||
trade_log = pd.read_csv(csv_path)
|
||||
total = len(trade_log)
|
||||
print(f"Loaded {total} trades from {csv_path}")
|
||||
|
||||
# Load data
|
||||
data = pd.read_csv(os.path.join(PROCESSED_DIR, f"{pair}_M15.csv"),
|
||||
index_col=0, parse_dates=True)
|
||||
data.index.name = "timestamp"
|
||||
data = compute_all_indicators(data)
|
||||
print(f"Loaded {len(data)} M15 bars")
|
||||
|
||||
out_dir = os.path.join(RESULTS_DIR, "trade_pngs")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Pick trades: first n_trades (or all if fewer)
|
||||
n = min(n_trades, total)
|
||||
print(f"Generating {n} trade PNGs...")
|
||||
|
||||
paths = []
|
||||
for i in range(n):
|
||||
row = trade_log.iloc[i]
|
||||
path = gen_trade_png(row, i + 1, total, data, pair, out_dir)
|
||||
if path:
|
||||
paths.append(path)
|
||||
print(f" [{i+1}/{n}] {path}")
|
||||
|
||||
print(f"\nDone. Generated {len(paths)} PNGs in {out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -240,6 +240,79 @@ def is_bearish_engulfing(df: pd.DataFrame, i: int) -> bool:
|
||||
curr["open"] >= prev["close"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSI Divergence Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trendline Fitting (Linear Regression with Outlier Pruning)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fit_trendline(timestamps: np.ndarray, prices: np.ndarray):
|
||||
"""
|
||||
Fit a trendline via linear regression with outlier pruning.
|
||||
|
||||
Args:
|
||||
timestamps: Numeric indices (e.g. bar indices) for x-axis.
|
||||
prices: Price values for y-axis.
|
||||
|
||||
Returns:
|
||||
dict with {slope, intercept, r_squared, touch_count, ref_idx}
|
||||
or None if insufficient quality (R² < 0.80 or < 3 points).
|
||||
"""
|
||||
if len(timestamps) < 3 or len(prices) < 3:
|
||||
return None
|
||||
|
||||
x = np.asarray(timestamps, dtype=float)
|
||||
y = np.asarray(prices, dtype=float)
|
||||
|
||||
# First fit
|
||||
coeffs = np.polyfit(x, y, 1)
|
||||
slope, intercept = coeffs[0], coeffs[1]
|
||||
|
||||
# Outlier pruning: remove points > 2 std dev from fit
|
||||
predicted = slope * x + intercept
|
||||
residuals = y - predicted
|
||||
std_res = np.std(residuals)
|
||||
if std_res > 0:
|
||||
mask = np.abs(residuals) <= 2.0 * std_res
|
||||
x_clean = x[mask]
|
||||
y_clean = y[mask]
|
||||
else:
|
||||
x_clean = x
|
||||
y_clean = y
|
||||
|
||||
if len(x_clean) < 3:
|
||||
return None
|
||||
|
||||
# Refit on cleaned data
|
||||
coeffs = np.polyfit(x_clean, y_clean, 1)
|
||||
slope, intercept = coeffs[0], coeffs[1]
|
||||
|
||||
# Compute R²
|
||||
predicted = slope * x_clean + intercept
|
||||
ss_res = np.sum((y_clean - predicted) ** 2)
|
||||
ss_tot = np.sum((y_clean - np.mean(y_clean)) ** 2)
|
||||
r_squared = 1.0 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
|
||||
|
||||
if r_squared < 0.80:
|
||||
return None
|
||||
|
||||
return {
|
||||
"slope": slope,
|
||||
"intercept": intercept,
|
||||
"r_squared": r_squared,
|
||||
"touch_count": len(x_clean),
|
||||
"ref_idx": int(x_clean[0]),
|
||||
}
|
||||
|
||||
|
||||
def project_trendline(slope: float, intercept: float, ref_idx: int,
|
||||
target_idx: int) -> float:
|
||||
"""Project trendline price at any bar index."""
|
||||
return slope * (target_idx - ref_idx) + intercept
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSI Divergence Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -296,7 +369,7 @@ def compute_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
df = df.copy()
|
||||
|
||||
# Moving Averages
|
||||
for period in [20, 50, 100, 200]:
|
||||
for period in [8, 13, 21, 34, 55, 20, 50, 100, 200]:
|
||||
df[f"ema_{period}"] = ema(df["close"], period)
|
||||
df["sma_200"] = sma(df["close"], 200)
|
||||
|
||||
@@ -312,8 +385,10 @@ def compute_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
# ADX
|
||||
df["adx_14"] = adx(df, 14)
|
||||
|
||||
# Stochastic
|
||||
# Stochastic (5-period default)
|
||||
df["stoch_k"], df["stoch_d"] = stochastic(df)
|
||||
# Stochastic 14-period
|
||||
df["stoch_k_14"], df["stoch_d_14"] = stochastic(df, k_period=14)
|
||||
|
||||
# Session VWAP (only meaningful for intraday timeframes)
|
||||
if len(df) > 0 and hasattr(df.index, "hour"):
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Run S4-D, S4-E, S4-F comparison on GBP_AUD, EUR_AUD, GBP_JPY.
|
||||
Runs all pairs for each variant (no early stopping)."""
|
||||
import os, sys, json, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1")
|
||||
PAIRS = ["GBP_AUD", "EUR_AUD", "GBP_JPY"]
|
||||
|
||||
|
||||
def load_data(pair, tf):
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
return compute_all_indicators(df)
|
||||
|
||||
|
||||
def run_variant(label, strat_cls):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
all_reports = []
|
||||
all_trades = []
|
||||
total_trade_count = 0
|
||||
|
||||
for pair in PAIRS:
|
||||
data = load_data(pair, "M15")
|
||||
htf_data = load_data(pair, "H1")
|
||||
print(f" {pair}: M15={len(data)}, H1={len(htf_data)}", end=" ")
|
||||
|
||||
strategy = strat_cls()
|
||||
bt = Backtester(data=data, strategy=strategy, pair=pair,
|
||||
starting_equity=100_000.0, htf_data=htf_data)
|
||||
|
||||
t0 = time.time()
|
||||
report = bt.run()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
trade_log = bt.get_trade_log_df()
|
||||
n = report.get("total_trades", 0)
|
||||
total_trade_count += n
|
||||
print(f"-> {n} trades ({elapsed:.0f}s)")
|
||||
|
||||
all_reports.append(report)
|
||||
if len(trade_log) > 0:
|
||||
all_trades.append(trade_log)
|
||||
|
||||
# Aggregate
|
||||
total_trades = sum(r.get("total_trades", 0) for r in all_reports)
|
||||
if total_trades == 0:
|
||||
print(f" NO TRADES across any pair!")
|
||||
return {"total_trades": 0}
|
||||
|
||||
total_wins = sum(r.get("total_trades", 0) * r.get("win_rate_pct", 0) / 100 for r in all_reports)
|
||||
total_pnl_pips = sum(r.get("total_pnl_pips", 0) for r in all_reports)
|
||||
total_pnl_dollars = sum(r.get("total_pnl_dollars", 0) for r in all_reports)
|
||||
|
||||
# Combine trade logs for PF calculation
|
||||
combined = pd.concat(all_trades, ignore_index=True) if all_trades else pd.DataFrame()
|
||||
if len(combined) > 0:
|
||||
gross_profit = combined.loc[combined["pnl_pips"] > 0, "pnl_pips"].sum()
|
||||
gross_loss = abs(combined.loc[combined["pnl_pips"] < 0, "pnl_pips"].sum())
|
||||
pf = gross_profit / gross_loss if gross_loss > 0 else 0
|
||||
avg_win = combined.loc[combined["pnl_pips"] > 0, "pnl_pips"].mean() if (combined["pnl_pips"] > 0).any() else 0
|
||||
avg_loss = abs(combined.loc[combined["pnl_pips"] < 0, "pnl_pips"].mean()) if (combined["pnl_pips"] < 0).any() else 0
|
||||
avg_rr = avg_win / avg_loss if avg_loss > 0 else 0
|
||||
max_dd = min(r.get("max_drawdown_pct", 0) for r in all_reports)
|
||||
else:
|
||||
pf = avg_win = avg_loss = avg_rr = 0
|
||||
max_dd = 0
|
||||
|
||||
win_rate = total_wins / total_trades * 100 if total_trades > 0 else 0
|
||||
expectancy = total_pnl_pips / total_trades if total_trades > 0 else 0
|
||||
|
||||
print(f"\n --- {label} AGGREGATE ---")
|
||||
print(f" Trades: {total_trades}")
|
||||
print(f" Win Rate: {win_rate:.1f}%")
|
||||
print(f" Avg RR: {avg_rr:.2f}")
|
||||
print(f" Expectancy: {expectancy:.2f} pips")
|
||||
print(f" Profit Factor: {pf:.2f}")
|
||||
print(f" Worst Max DD: {max_dd:.2f}%")
|
||||
print(f" Total PnL: {total_pnl_pips:.1f} pips / ${total_pnl_dollars:,.2f}")
|
||||
print(f" Avg Win: {avg_win:.1f}p | Avg Loss: {avg_loss:.1f}p")
|
||||
|
||||
# Per-pair breakdown
|
||||
for i, pair in enumerate(PAIRS):
|
||||
if i < len(all_reports):
|
||||
r = all_reports[i]
|
||||
n = r.get("total_trades", 0)
|
||||
wr = r.get("win_rate_pct", 0)
|
||||
pfp = r.get("profit_factor", 0)
|
||||
pnl = r.get("total_pnl_pips", 0)
|
||||
print(f" {pair}: {n} trades, WR {wr:.1f}%, PF {pfp:.2f}, PnL {pnl:.1f}p")
|
||||
|
||||
return {
|
||||
"total_trades": total_trades, "win_rate": win_rate, "avg_rr": avg_rr,
|
||||
"expectancy": expectancy, "profit_factor": pf, "max_dd": max_dd,
|
||||
"total_pnl_pips": total_pnl_pips, "total_pnl_dollars": total_pnl_dollars,
|
||||
"avg_win": avg_win, "avg_loss": avg_loss,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
variant = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
|
||||
results = {}
|
||||
|
||||
if variant in ("D", "all"):
|
||||
from src.strategies_pkg.s4d_ema_ribbon import S4D_EMA_Ribbon
|
||||
results["S4-D"] = run_variant("S4-D (Volume+ADX)", S4D_EMA_Ribbon)
|
||||
|
||||
if variant in ("E", "all"):
|
||||
from src.strategies_pkg.s4e_ema_ribbon import S4E_EMA_Ribbon
|
||||
results["S4-E"] = run_variant("S4-E (Compression Quality)", S4E_EMA_Ribbon)
|
||||
|
||||
if variant in ("F", "all"):
|
||||
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
|
||||
results["S4-F"] = run_variant("S4-F (Trend Context)", S4F_EMA_Ribbon)
|
||||
|
||||
if len(results) > 1:
|
||||
print("\n" + "=" * 70)
|
||||
print("COMPARISON")
|
||||
print("=" * 70)
|
||||
print(f"{'Metric':<20}", end="")
|
||||
for k in results:
|
||||
print(f"{k:>18}", end="")
|
||||
print()
|
||||
print("-" * (20 + 18 * len(results)))
|
||||
for metric in ["total_trades", "win_rate", "avg_rr", "expectancy",
|
||||
"profit_factor", "max_dd", "total_pnl_pips", "total_pnl_dollars",
|
||||
"avg_win", "avg_loss"]:
|
||||
print(f"{metric:<20}", end="")
|
||||
for k in results:
|
||||
v = results[k].get(metric, 0)
|
||||
if isinstance(v, float):
|
||||
print(f"{v:>18.2f}", end="")
|
||||
else:
|
||||
print(f"{v:>18}", end="")
|
||||
print()
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Run S4-F-v2 on GBP_AUD, EUR_AUD, GBP_JPY using M5 entry, M15 ribbon, H1 trend.
|
||||
|
||||
Uses 2023-2024 data only (18 months for speed as per spec).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
from src.strategies_pkg.s4fv2_ema_ribbon import S4Fv2_EMA_Ribbon
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
PAIRS = ["GBP_AUD", "EUR_AUD", "GBP_JPY"]
|
||||
|
||||
# 2023-01-01 to 2024-06-30 (18 months)
|
||||
DATE_START = "2023-01-01"
|
||||
DATE_END = "2024-07-01"
|
||||
|
||||
|
||||
def load_data(pair, tf):
|
||||
"""Load data, compute indicators, filter to date range."""
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||
if not os.path.exists(fp):
|
||||
print(f" WARNING: {fp} not found!")
|
||||
return None
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
df = compute_all_indicators(df)
|
||||
# Filter date range
|
||||
df = df[(df.index >= DATE_START) & (df.index < DATE_END)]
|
||||
return df
|
||||
|
||||
|
||||
def run_pair(pair):
|
||||
"""Run S4-F-v2 on a single pair."""
|
||||
print(f"\n --- {pair} ---")
|
||||
|
||||
# Load all 3 timeframes
|
||||
print(f" Loading M5...", end=" ", flush=True)
|
||||
m5_data = load_data(pair, "M5")
|
||||
if m5_data is None:
|
||||
return None
|
||||
print(f"{len(m5_data)} bars")
|
||||
|
||||
print(f" Loading M15...", end=" ", flush=True)
|
||||
m15_data = load_data(pair, "M15")
|
||||
if m15_data is None:
|
||||
return None
|
||||
print(f"{len(m15_data)} bars")
|
||||
|
||||
print(f" Loading H1...", end=" ", flush=True)
|
||||
h1_data = load_data(pair, "H1")
|
||||
if h1_data is None:
|
||||
return None
|
||||
print(f"{len(h1_data)} bars")
|
||||
|
||||
# Create strategy and attach M15 data
|
||||
strategy = S4Fv2_EMA_Ribbon()
|
||||
strategy.m15_data = m15_data
|
||||
|
||||
# Run backtest: M5 as primary, H1 as HTF
|
||||
bt = Backtester(
|
||||
data=m5_data,
|
||||
strategy=strategy,
|
||||
pair=pair,
|
||||
starting_equity=100_000.0,
|
||||
htf_data=h1_data,
|
||||
)
|
||||
|
||||
print(f" Running backtest...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
report = bt.run()
|
||||
elapsed = time.time() - t0
|
||||
trade_log = bt.get_trade_log_df()
|
||||
n = report.get("total_trades", 0)
|
||||
print(f"{n} trades in {elapsed:.0f}s")
|
||||
|
||||
return report, trade_log
|
||||
|
||||
|
||||
def print_report(report):
|
||||
"""Print key metrics."""
|
||||
n = report.get("total_trades", 0)
|
||||
if n == 0:
|
||||
print(f" No trades!")
|
||||
return
|
||||
print(f" Trades: {n}")
|
||||
print(f" Win Rate: {report.get('win_rate_pct', 0):.1f}%")
|
||||
print(f" Avg RR: {report.get('avg_rr', 0):.2f}")
|
||||
print(f" Profit Factor: {report.get('profit_factor', 0):.2f}")
|
||||
print(f" Expectancy: {report.get('expectancy_pips', 0):.2f} pips")
|
||||
print(f" Max DD: {report.get('max_drawdown_pct', 0):.2f}%")
|
||||
print(f" Total PnL: {report.get('total_pnl_pips', 0):.1f} pips / ${report.get('total_pnl_dollars', 0):,.2f}")
|
||||
print(f" Avg Win: {report.get('avg_win_pips', 0):.1f}p | Avg Loss: {report.get('avg_loss_pips', 0):.1f}p")
|
||||
print(f" Exits: {report.get('exit_reasons', {})}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("S4-F-v2: Trend Context Quick Tune")
|
||||
print(f"Period: {DATE_START} to {DATE_END} (18 months)")
|
||||
print(f"Pairs: {', '.join(PAIRS)}")
|
||||
print(f"Entry TF: M5 | Ribbon TF: M15 | Trend TF: H1")
|
||||
print("=" * 60)
|
||||
|
||||
all_reports = []
|
||||
all_trades = []
|
||||
|
||||
for pair in PAIRS:
|
||||
result = run_pair(pair)
|
||||
if result is None:
|
||||
continue
|
||||
report, trade_log = result
|
||||
print_report(report)
|
||||
all_reports.append(report)
|
||||
if len(trade_log) > 0:
|
||||
all_trades.append(trade_log)
|
||||
|
||||
# Aggregate
|
||||
total_trades = sum(r.get("total_trades", 0) for r in all_reports)
|
||||
if total_trades == 0:
|
||||
print("\nNO TRADES across any pair!")
|
||||
return
|
||||
|
||||
total_pnl_pips = sum(r.get("total_pnl_pips", 0) for r in all_reports)
|
||||
total_pnl_dollars = sum(r.get("total_pnl_dollars", 0) for r in all_reports)
|
||||
|
||||
combined = pd.concat(all_trades, ignore_index=True) if all_trades else pd.DataFrame()
|
||||
if len(combined) > 0:
|
||||
wins = combined[combined["pnl_pips"] > 0]
|
||||
losses = combined[combined["pnl_pips"] < 0]
|
||||
gross_profit = wins["pnl_pips"].sum() if len(wins) > 0 else 0
|
||||
gross_loss = abs(losses["pnl_pips"].sum()) if len(losses) > 0 else 0
|
||||
pf = gross_profit / gross_loss if gross_loss > 0 else 0
|
||||
avg_win = wins["pnl_pips"].mean() if len(wins) > 0 else 0
|
||||
avg_loss = abs(losses["pnl_pips"].mean()) if len(losses) > 0 else 0
|
||||
avg_rr = avg_win / avg_loss if avg_loss > 0 else 0
|
||||
win_rate = len(wins) / total_trades * 100
|
||||
max_dd = min(r.get("max_drawdown_pct", 0) for r in all_reports)
|
||||
else:
|
||||
pf = avg_win = avg_loss = avg_rr = win_rate = 0
|
||||
max_dd = 0
|
||||
|
||||
expectancy = total_pnl_pips / total_trades if total_trades > 0 else 0
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"S4-F-v2 AGGREGATE RESULTS")
|
||||
print(f"{'='*60}")
|
||||
print(f" Total Trades: {total_trades}")
|
||||
print(f" Win Rate: {win_rate:.1f}%")
|
||||
print(f" Avg RR: {avg_rr:.2f}")
|
||||
print(f" Expectancy: {expectancy:.2f} pips/trade")
|
||||
print(f" Profit Factor: {pf:.2f}")
|
||||
print(f" Worst Max DD: {max_dd:.2f}%")
|
||||
print(f" Total PnL: {total_pnl_pips:.1f} pips / ${total_pnl_dollars:,.2f}")
|
||||
print(f" Avg Win: {avg_win:.1f}p | Avg Loss: {avg_loss:.1f}p")
|
||||
|
||||
# Target check
|
||||
print(f"\n{'='*60}")
|
||||
print(f"TARGET CHECK")
|
||||
print(f"{'='*60}")
|
||||
t_pass = "PASS" if 150 <= total_trades <= 200 else ("CLOSE" if 100 <= total_trades <= 250 else "FAIL")
|
||||
w_pass = "PASS" if win_rate > 48 else "FAIL"
|
||||
p_pass = "PASS" if pf > 0.9 else "FAIL"
|
||||
print(f" Trades 150-200: {total_trades:>6} [{t_pass}]")
|
||||
print(f" Win Rate >48%: {win_rate:>5.1f}% [{w_pass}]")
|
||||
print(f" PF >0.9: {pf:>6.2f} [{p_pass}]")
|
||||
|
||||
# Per-pair summary
|
||||
print(f"\n{'='*60}")
|
||||
print(f"PER-PAIR BREAKDOWN")
|
||||
print(f"{'='*60}")
|
||||
print(f"{'Pair':<12} {'Trades':>7} {'WR':>7} {'PF':>7} {'PnL':>10}")
|
||||
print("-" * 45)
|
||||
for r in all_reports:
|
||||
p = r.get("pair", "?")
|
||||
print(f"{p:<12} {r.get('total_trades', 0):>7} "
|
||||
f"{r.get('win_rate_pct', 0):>6.1f}% "
|
||||
f"{r.get('profit_factor', 0):>7.2f} "
|
||||
f"{r.get('total_pnl_pips', 0):>9.1f}p")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
"""Run S4-G Pullback-First on EUR_AUD only (quick validation).
|
||||
|
||||
Period: 2024-01-01 to 2024-06-30 (6 months for speed).
|
||||
3 timeframes: M5 entry, M15 ribbon/ATR, H1 trend.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
from src.strategies_pkg.s4g_pullback import S4G_Pullback
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||
PROCESSED_DIR = os.path.join(PROJECT_ROOT, "data", "processed")
|
||||
TEST_SET_DIR = os.path.join(PROJECT_ROOT, "data", "test_set")
|
||||
|
||||
DATE_START = "2024-01-01"
|
||||
DATE_END = "2024-07-01"
|
||||
PAIR = "EUR_AUD"
|
||||
|
||||
|
||||
def load_data(pair, tf):
|
||||
"""Load data, combining processed + test_set to cover full date range."""
|
||||
dfs = []
|
||||
for d in [PROCESSED_DIR, TEST_SET_DIR]:
|
||||
fp = os.path.join(d, f"{pair}_{tf}.csv")
|
||||
if os.path.exists(fp):
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
dfs.append(df)
|
||||
if not dfs:
|
||||
print(f" WARNING: No data found for {pair}_{tf}")
|
||||
return None
|
||||
combined = pd.concat(dfs)
|
||||
combined = combined[~combined.index.duplicated(keep="last")]
|
||||
combined = combined.sort_index()
|
||||
combined = compute_all_indicators(combined)
|
||||
combined = combined[(combined.index >= DATE_START) & (combined.index < DATE_END)]
|
||||
return combined
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("S4-G: Pullback-First (Quick Validation)")
|
||||
print(f"Pair: {PAIR} | Period: {DATE_START} to {DATE_END}")
|
||||
print(f"Entry TF: M5 | Ribbon TF: M15 | Trend TF: H1")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n Loading M5...", end=" ", flush=True)
|
||||
m5 = load_data(PAIR, "M5")
|
||||
if m5 is None:
|
||||
return
|
||||
print(f"{len(m5)} bars")
|
||||
|
||||
print(f" Loading M15...", end=" ", flush=True)
|
||||
m15 = load_data(PAIR, "M15")
|
||||
if m15 is None:
|
||||
return
|
||||
print(f"{len(m15)} bars")
|
||||
|
||||
print(f" Loading H1...", end=" ", flush=True)
|
||||
h1 = load_data(PAIR, "H1")
|
||||
if h1 is None:
|
||||
return
|
||||
print(f"{len(h1)} bars")
|
||||
|
||||
# Create strategy and attach M15 data
|
||||
strategy = S4G_Pullback()
|
||||
strategy.m15_data = m15
|
||||
|
||||
# Run backtest: M5 primary, H1 as HTF
|
||||
bt = Backtester(
|
||||
data=m5,
|
||||
strategy=strategy,
|
||||
pair=PAIR,
|
||||
starting_equity=100_000.0,
|
||||
htf_data=h1,
|
||||
)
|
||||
|
||||
print(f"\n Running backtest...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
report = bt.run()
|
||||
elapsed = time.time() - t0
|
||||
trade_log = bt.get_trade_log_df()
|
||||
n = report.get("total_trades", 0)
|
||||
print(f"{n} trades in {elapsed:.0f}s")
|
||||
|
||||
# Report
|
||||
print(f"\n{'='*60}")
|
||||
print(f"S4-G RESULTS — {PAIR}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if n == 0:
|
||||
print(" NO TRADES!")
|
||||
print("\n VERDICT: ABANDON (0 trades)")
|
||||
return
|
||||
|
||||
wr = report.get("win_rate_pct", 0)
|
||||
pf = report.get("profit_factor", 0)
|
||||
pnl = report.get("total_pnl_pips", 0)
|
||||
pnl_d = report.get("total_pnl_dollars", 0)
|
||||
dd = report.get("max_drawdown_pct", 0)
|
||||
|
||||
print(f" Total Trades: {n}")
|
||||
print(f" Win Rate: {wr:.1f}%")
|
||||
print(f" Profit Factor: {pf:.2f}")
|
||||
print(f" Avg RR: {report.get('avg_rr', 0):.2f}")
|
||||
print(f" Expectancy: {report.get('expectancy_pips', 0):.2f} pips")
|
||||
print(f" Max DD: {dd:.2f}%")
|
||||
print(f" Total PnL: {pnl:.1f} pips / ${pnl_d:,.2f}")
|
||||
print(f" Avg Win: {report.get('avg_win_pips', 0):.1f}p")
|
||||
print(f" Avg Loss: {report.get('avg_loss_pips', 0):.1f}p")
|
||||
print(f" Exits: {report.get('exit_reasons', {})}")
|
||||
|
||||
# Decision gate
|
||||
print(f"\n{'='*60}")
|
||||
print(f"DECISION GATE")
|
||||
print(f"{'='*60}")
|
||||
if n > 10 and pf > 0.9:
|
||||
print(f" PASS: {n} trades, PF {pf:.2f}")
|
||||
print(f" -> Run full dataset test")
|
||||
elif n < 10 or pf < 0.8:
|
||||
print(f" FAIL: {n} trades, PF {pf:.2f}")
|
||||
print(f" -> Abandon, move to Smart Money strategies")
|
||||
else:
|
||||
print(f" BORDERLINE: {n} trades, PF {pf:.2f}")
|
||||
print(f" -> Consider tweaks or expand test period")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Run S6A vs S6B comparison backtest on GBP_AUD."""
|
||||
import os, sys, json, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
from src.strategies_pkg.s6a_ema_bounce import S6A_EMA_Bounce
|
||||
from src.strategies_pkg.s6b_ema_bounce import S6B_EMA_Bounce
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1")
|
||||
PAIR = "GBP_AUD"
|
||||
|
||||
|
||||
def load_data(pair, tf):
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
return compute_all_indicators(df)
|
||||
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def run_variant(label, strat_cls):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
data = load_data(PAIR, "M15")
|
||||
htf_data = load_data(PAIR, "H1")
|
||||
print(f" M15 bars: {len(data)}, H1 bars: {len(htf_data)}")
|
||||
|
||||
strategy = strat_cls()
|
||||
bt = Backtester(data=data, strategy=strategy, pair=PAIR,
|
||||
starting_equity=100_000.0, htf_data=htf_data)
|
||||
|
||||
t0 = time.time()
|
||||
report = bt.run()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
trade_log = bt.get_trade_log_df()
|
||||
|
||||
total = report.get("total_trades", 0)
|
||||
print(f" Time: {elapsed:.1f}s")
|
||||
print(f" Trades: {total}")
|
||||
print(f" Win Rate: {report.get('win_rate_pct', 0):.1f}%")
|
||||
print(f" Avg RR: {report.get('avg_rr', 0):.2f}")
|
||||
print(f" Expectancy: {report.get('expectancy_pips', 0):.2f} pips")
|
||||
print(f" Sharpe: {report.get('sharpe_ratio', 0):.2f}")
|
||||
print(f" Max DD: {report.get('max_drawdown_pct', 0):.2f}%")
|
||||
print(f" Profit Factor: {report.get('profit_factor', 0):.2f}")
|
||||
print(f" Total PnL: {report.get('total_pnl_pips', 0):.1f} pips / ${report.get('total_pnl_dollars', 0):,.2f}")
|
||||
print(f" Final Equity: ${report.get('final_equity', 0):,.2f}")
|
||||
print(f" Exits: {report.get('exit_reasons', {})}")
|
||||
|
||||
if total > 0:
|
||||
print(f" Avg Win: {report.get('avg_win_pips', 0):.1f}p | Avg Loss: {report.get('avg_loss_pips', 0):.1f}p")
|
||||
print(f" Best: {report.get('best_trade_pips', 0):.1f}p | Worst: {report.get('worst_trade_pips', 0):.1f}p")
|
||||
print(f" Max Consec Wins: {report.get('max_consecutive_wins', 0)} | Losses: {report.get('max_consecutive_losses', 0)}")
|
||||
|
||||
# Save results
|
||||
prefix = label.replace(" ", "_")
|
||||
report_path = os.path.join(RESULTS_DIR, f"{prefix}_{PAIR}_report.json")
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(report, f, indent=2, default=str)
|
||||
|
||||
if len(trade_log) > 0:
|
||||
log_path = os.path.join(RESULTS_DIR, f"{prefix}_{PAIR}_trades.csv")
|
||||
trade_log.to_csv(log_path, index=False)
|
||||
|
||||
return report, trade_log
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("S6 MOMENTUM FILTER COMPARISON — GBP_AUD")
|
||||
print("=" * 60)
|
||||
|
||||
r_a, tl_a = run_variant("S6A", S6A_EMA_Bounce)
|
||||
r_b, tl_b = run_variant("S6B", S6B_EMA_Bounce)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("COMPARISON SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"{'Metric':<25} {'S6A (3-Check)':>15} {'S6B (2-Check)':>15}")
|
||||
print("-" * 55)
|
||||
for key in ["total_trades", "win_rate_pct", "avg_rr", "expectancy_pips",
|
||||
"sharpe_ratio", "max_drawdown_pct", "profit_factor",
|
||||
"total_pnl_pips", "total_pnl_dollars", "final_equity"]:
|
||||
va = r_a.get(key, 0)
|
||||
vb = r_b.get(key, 0)
|
||||
if isinstance(va, float):
|
||||
print(f"{key:<25} {va:>15.2f} {vb:>15.2f}")
|
||||
else:
|
||||
print(f"{key:<25} {va:>15} {vb:>15}")
|
||||
@@ -1,31 +1,38 @@
|
||||
from .s1_ma_breakout import S1_MA_Breakout
|
||||
from .s2_vwap_reversal import S2_VWAP_Reversal
|
||||
# S2 disabled: 20-25% win rate, 26 consecutive losses, needs full redesign
|
||||
# from .s2_vwap_reversal import S2_VWAP_Reversal
|
||||
from .s3_key_level_breakout import S3_KeyLevel_Breakout
|
||||
from .s4_ema_ribbon import S4_EMA_Ribbon
|
||||
from .s5_momentum_exhaustion import S5_Momentum_Exhaustion
|
||||
from .s6_ema_bounce import S6_EMA_Bounce
|
||||
|
||||
STRATEGIES = {
|
||||
1: S1_MA_Breakout,
|
||||
2: S2_VWAP_Reversal,
|
||||
# 2: S2_VWAP_Reversal, # DISABLED
|
||||
3: S3_KeyLevel_Breakout,
|
||||
4: S4_EMA_Ribbon,
|
||||
5: S5_Momentum_Exhaustion,
|
||||
6: S6_EMA_Bounce,
|
||||
}
|
||||
|
||||
# Which pairs each strategy trades
|
||||
# Allowed universe: GBP_AUD, EUR_AUD, EUR_CAD, GBP_CAD, GBP_USD, EUR_USD
|
||||
# Removed: EUR_GBP (PF 0.38-0.43), EUR_NZD (S1 lost $25k)
|
||||
STRATEGY_PAIRS = {
|
||||
1: ["GBP_AUD", "EUR_AUD", "EUR_CAD", "EUR_NZD"],
|
||||
2: ["GBP_USD", "EUR_USD", "GBP_JPY", "USD_JPY"],
|
||||
3: ["GBP_JPY", "USD_JPY", "GBP_USD", "EUR_GBP"],
|
||||
4: ["GBP_AUD", "EUR_AUD", "EUR_GBP"],
|
||||
5: ["GBP_AUD", "EUR_AUD", "EUR_GBP", "GBP_CAD", "EUR_CAD"],
|
||||
1: ["GBP_AUD", "EUR_AUD", "EUR_CAD", "GBP_CAD"],
|
||||
# 2: DISABLED
|
||||
3: ["GBP_JPY", "USD_JPY", "GBP_USD"],
|
||||
4: ["GBP_AUD", "EUR_AUD", "GBP_JPY"],
|
||||
5: ["GBP_AUD", "EUR_AUD", "GBP_JPY", "USD_JPY", "GBP_USD"],
|
||||
6: ["GBP_AUD"], # Initial test — expand to EUR_AUD, GBP_USD if passing
|
||||
}
|
||||
|
||||
# Primary and filter timeframes
|
||||
STRATEGY_TIMEFRAMES = {
|
||||
1: {"primary": "M15", "filter": "H1"},
|
||||
2: {"primary": "M15", "filter": None},
|
||||
# 2: DISABLED
|
||||
3: {"primary": "H1", "filter": None}, # Uses internal key level detection
|
||||
4: {"primary": "M15", "filter": "H1"},
|
||||
5: {"primary": "M15", "filter": "H1"},
|
||||
6: {"primary": "M15", "filter": "H1"},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ class BaseStrategy:
|
||||
strategy_id: int = 0
|
||||
name: str = "BaseStrategy"
|
||||
|
||||
def __init__(self):
|
||||
self.htf_data = None
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
|
||||
@@ -1,33 +1,375 @@
|
||||
"""
|
||||
Strategy 1: MA Breakout-Retest (Option 3 - no trendlines).
|
||||
Strategy 1: Trendline Breakout-Retest.
|
||||
|
||||
Uses MA structure + key level breaks + candle confirmation.
|
||||
Entry TF: M15, Filter TF: H1 (200 SMA directional filter).
|
||||
4-step sequence identified on H1 chart with M15 entry:
|
||||
1. Identify trendline on H1 (3+ swing touches, linear regression)
|
||||
2. Breakout: H1 close beyond trendline with conviction
|
||||
3. Move away: Price moves away from trendline (confirms real break)
|
||||
4. Retest + Entry: Price pulls back to broken trendline on M15 → engulfing candle
|
||||
|
||||
Entry conditions (LONG):
|
||||
- EMA 50 > EMA 100 > EMA 200 (trend alignment)
|
||||
- Price pulls back to EMA 50 zone (within 1.0x ATR)
|
||||
- Bullish confirmation candle (close > open, close > prev close, body > 30% range)
|
||||
- H1 close > H1 200 SMA (HTF filter)
|
||||
- Session filter: London/NY hours only (08:00-17:00 UTC)
|
||||
Entry conditions (all must be true):
|
||||
- State machine in RETEST phase
|
||||
- M15 engulfing candle
|
||||
- M15 close within 1.0x ATR of projected trendline price
|
||||
- M15 EMA 50 aligns with direction
|
||||
- Session: London/NY overlap (13:00-16:00 UTC)
|
||||
- Confluence >= 2
|
||||
|
||||
Boosters (confluence 0-5):
|
||||
- Volume above 20-period average
|
||||
- RSI between 40-60 (not overextended)
|
||||
- MACD histogram positive and rising
|
||||
- ADX > 20 (trending)
|
||||
- Price above session VWAP
|
||||
SL: Projected trendline price +/- 0.5x ATR (behind the trendline)
|
||||
TP1: Previous swing high/low (structure), fallback 1.5x ATR
|
||||
TP2: Next key level or 2.5x ATR
|
||||
TP3: 2x TP1 distance or 4x ATR (runner)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
from ..indicators.technical import (
|
||||
fit_trendline, project_trendline, swing_highs, swing_lows,
|
||||
is_bullish_engulfing, is_bearish_engulfing, identify_key_levels,
|
||||
)
|
||||
|
||||
|
||||
class S1_MA_Breakout(BaseStrategy):
|
||||
"""Trendline Breakout-Retest strategy (renamed from MA Breakout)."""
|
||||
strategy_id = 1
|
||||
name = "S1_MA_Breakout_Retest"
|
||||
name = "S1_Trendline_Breakout_Retest"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._trendlines = {"resistance": None, "support": None}
|
||||
self._tl_cache_idx = -1
|
||||
self._state = {
|
||||
"phase": "IDLE",
|
||||
"direction": None,
|
||||
"break_bar_idx": None,
|
||||
"break_price": None,
|
||||
"trendline": None,
|
||||
"max_dist": 0.0,
|
||||
"bars_since_break": 0,
|
||||
}
|
||||
# Performance: cached HTF timestamps for searchsorted
|
||||
self._htf_ts_cache = None
|
||||
self._last_htf_cutoff = -1 # tracks H1 bar changes for timeout
|
||||
|
||||
def _htf_cutoff(self, htf: pd.DataFrame, ts: pd.Timestamp) -> int:
|
||||
"""Return number of H1 bars strictly before ts, using searchsorted."""
|
||||
if self._htf_ts_cache is None:
|
||||
self._htf_ts_cache = htf.index
|
||||
# Normalize tz: strip tz from ts if HTF index is tz-naive, or vice versa
|
||||
if self._htf_ts_cache.tz is None and hasattr(ts, 'tz') and ts.tz is not None:
|
||||
ts = ts.tz_localize(None)
|
||||
elif self._htf_ts_cache.tz is not None and (not hasattr(ts, 'tz') or ts.tz is None):
|
||||
ts = ts.tz_localize(self._htf_ts_cache.tz)
|
||||
return int(self._htf_ts_cache.searchsorted(ts, side="left"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trendline Detection (runs on H1 data)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_trendlines(self, htf: pd.DataFrame, n_valid: int):
|
||||
"""
|
||||
Detect resistance and support trendlines from H1 swing points.
|
||||
n_valid = number of H1 bars before current M15 timestamp.
|
||||
Recalculates every 20 H1 bars.
|
||||
"""
|
||||
if n_valid < 50:
|
||||
return
|
||||
|
||||
if (self._tl_cache_idx >= 0 and
|
||||
n_valid - self._tl_cache_idx < 20):
|
||||
return
|
||||
|
||||
self._tl_cache_idx = n_valid
|
||||
|
||||
# Use last 200 H1 bars (no lookahead: only first n_valid bars)
|
||||
start = max(0, n_valid - 200)
|
||||
window = htf.iloc[start:n_valid]
|
||||
offset = start # absolute index of window[0] in htf
|
||||
|
||||
# Detect swing highs and lows
|
||||
sh_mask = swing_highs(window, lookback=5)
|
||||
sl_mask = swing_lows(window, lookback=5)
|
||||
|
||||
# Resistance trendline from swing highs
|
||||
sh_indices = np.where(sh_mask.values)[0]
|
||||
if len(sh_indices) >= 3:
|
||||
recent_sh = sh_indices[-8:]
|
||||
sh_prices = window["high"].values[recent_sh]
|
||||
tl = fit_trendline(recent_sh, sh_prices)
|
||||
if tl is not None:
|
||||
tl["window_offset"] = offset
|
||||
self._trendlines["resistance"] = tl
|
||||
else:
|
||||
self._trendlines["resistance"] = None
|
||||
|
||||
# Support trendline from swing lows
|
||||
sl_indices_arr = np.where(sl_mask.values)[0]
|
||||
if len(sl_indices_arr) >= 3:
|
||||
recent_sl = sl_indices_arr[-8:]
|
||||
sl_prices = window["low"].values[recent_sl]
|
||||
tl = fit_trendline(recent_sl, sl_prices)
|
||||
if tl is not None:
|
||||
tl["window_offset"] = offset
|
||||
self._trendlines["support"] = tl
|
||||
else:
|
||||
self._trendlines["support"] = None
|
||||
|
||||
def _project_tl_at_htf_bar(self, tl: dict, htf_bar_idx: int) -> float:
|
||||
"""Project trendline price at a given absolute HTF bar index."""
|
||||
window_rel_idx = htf_bar_idx - tl["window_offset"]
|
||||
return tl["slope"] * window_rel_idx + tl["intercept"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State Machine
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_state_machine(self, htf: pd.DataFrame, n_valid: int):
|
||||
"""
|
||||
Check for breakout transitions on H1 data.
|
||||
n_valid = number of H1 bars strictly before current M15 timestamp.
|
||||
"""
|
||||
if n_valid < 2:
|
||||
return
|
||||
|
||||
last_h1_idx = n_valid - 1
|
||||
last_h1 = htf.iloc[last_h1_idx]
|
||||
|
||||
# H1 ATR for thresholds
|
||||
h1_atr = last_h1.get("atr_14", 0)
|
||||
if h1_atr <= 0 or np.isnan(h1_atr):
|
||||
return
|
||||
|
||||
phase = self._state["phase"]
|
||||
|
||||
# Track H1 bar changes for timeout counter
|
||||
if phase != "IDLE":
|
||||
if last_h1_idx != self._last_htf_cutoff:
|
||||
self._last_htf_cutoff = last_h1_idx
|
||||
self._state["bars_since_break"] += 1
|
||||
if self._state["bars_since_break"] > 50:
|
||||
self._reset_state()
|
||||
return
|
||||
|
||||
if phase == "IDLE":
|
||||
# Check for breakout above resistance -> LONG
|
||||
res_tl = self._trendlines.get("resistance")
|
||||
if res_tl is not None:
|
||||
tl_price = self._project_tl_at_htf_bar(res_tl, last_h1_idx)
|
||||
threshold = tl_price + 0.3 * h1_atr
|
||||
h1_close = last_h1["close"]
|
||||
h1_open = last_h1["open"]
|
||||
body_low = min(h1_close, h1_open)
|
||||
if h1_close > threshold and body_low > tl_price:
|
||||
self._state = {
|
||||
"phase": "MOVE_AWAY",
|
||||
"direction": "LONG",
|
||||
"break_bar_idx": last_h1_idx,
|
||||
"break_price": h1_close,
|
||||
"trendline": res_tl.copy(),
|
||||
"max_dist": h1_close - tl_price,
|
||||
"bars_since_break": 0,
|
||||
"h1_atr": h1_atr,
|
||||
}
|
||||
self._last_htf_cutoff = last_h1_idx
|
||||
return
|
||||
|
||||
# Check for breakout below support -> SHORT
|
||||
sup_tl = self._trendlines.get("support")
|
||||
if sup_tl is not None:
|
||||
tl_price = self._project_tl_at_htf_bar(sup_tl, last_h1_idx)
|
||||
threshold = tl_price - 0.3 * h1_atr
|
||||
h1_close = last_h1["close"]
|
||||
h1_open = last_h1["open"]
|
||||
body_high = max(h1_close, h1_open)
|
||||
if h1_close < threshold and body_high < tl_price:
|
||||
self._state = {
|
||||
"phase": "MOVE_AWAY",
|
||||
"direction": "SHORT",
|
||||
"break_bar_idx": last_h1_idx,
|
||||
"break_price": h1_close,
|
||||
"trendline": sup_tl.copy(),
|
||||
"max_dist": tl_price - h1_close,
|
||||
"bars_since_break": 0,
|
||||
"h1_atr": h1_atr,
|
||||
}
|
||||
self._last_htf_cutoff = last_h1_idx
|
||||
return
|
||||
|
||||
elif phase == "MOVE_AWAY":
|
||||
tl = self._state["trendline"]
|
||||
tl_price = self._project_tl_at_htf_bar(tl, last_h1_idx)
|
||||
h1_close = last_h1["close"]
|
||||
state_atr = self._state.get("h1_atr", h1_atr)
|
||||
|
||||
if self._state["direction"] == "LONG":
|
||||
dist = h1_close - tl_price
|
||||
if dist > self._state["max_dist"]:
|
||||
self._state["max_dist"] = dist
|
||||
if self._state["max_dist"] >= 0.5 * state_atr and dist < self._state["max_dist"]:
|
||||
self._state["phase"] = "RETEST"
|
||||
else: # SHORT
|
||||
dist = tl_price - h1_close
|
||||
if dist > self._state["max_dist"]:
|
||||
self._state["max_dist"] = dist
|
||||
if self._state["max_dist"] >= 0.5 * state_atr and dist < self._state["max_dist"]:
|
||||
self._state["phase"] = "RETEST"
|
||||
|
||||
def _reset_state(self):
|
||||
self._state = {
|
||||
"phase": "IDLE",
|
||||
"direction": None,
|
||||
"break_bar_idx": None,
|
||||
"break_price": None,
|
||||
"trendline": None,
|
||||
"max_dist": 0.0,
|
||||
"bars_since_break": 0,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confluence Scoring (0-5)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _calc_confluence(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series, direction: str,
|
||||
tl: dict) -> int:
|
||||
confluence = 0
|
||||
|
||||
# Trendline R-squared > 0.90
|
||||
if tl.get("r_squared", 0) > 0.90:
|
||||
confluence += 1
|
||||
|
||||
# Touch count >= 4
|
||||
if tl.get("touch_count", 0) >= 4:
|
||||
confluence += 1
|
||||
|
||||
# Volume above 20-period average
|
||||
if "volume" in current.index:
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and current["volume"] > vol_avg:
|
||||
confluence += 1
|
||||
|
||||
# RSI between 40-60
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if not np.isnan(rsi_val) and 40 <= rsi_val <= 60:
|
||||
confluence += 1
|
||||
|
||||
# MACD histogram confirms direction
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
if not np.isnan(macd_h):
|
||||
if direction == "LONG" and macd_h > 0:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and macd_h < 0:
|
||||
confluence += 1
|
||||
|
||||
return min(confluence, 5)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Find structure-based TP levels from H1 data
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _find_structure_tp(self, htf: pd.DataFrame, n_valid: int,
|
||||
direction: str, entry_price: float,
|
||||
atr_val: float) -> tuple:
|
||||
"""Find TP levels based on H1 swing structure and key levels."""
|
||||
if n_valid < 50:
|
||||
if direction == "LONG":
|
||||
return (entry_price + 1.5 * atr_val,
|
||||
entry_price + 2.5 * atr_val,
|
||||
entry_price + 4.0 * atr_val)
|
||||
else:
|
||||
return (entry_price - 1.5 * atr_val,
|
||||
entry_price - 2.5 * atr_val,
|
||||
entry_price - 4.0 * atr_val)
|
||||
|
||||
start = max(0, n_valid - 100)
|
||||
window = htf.iloc[start:n_valid]
|
||||
|
||||
if direction == "LONG":
|
||||
# TP1: previous swing high above entry
|
||||
sh_mask = swing_highs(window, lookback=5)
|
||||
sh_prices = window.loc[sh_mask, "high"]
|
||||
above = sh_prices[sh_prices > entry_price].sort_values()
|
||||
tp1 = above.iloc[0] if len(above) > 0 else entry_price + 1.5 * atr_val
|
||||
|
||||
# TP2: next key level above TP1, or 2.5x ATR
|
||||
levels = identify_key_levels(window, lookback=5, min_touches=2)
|
||||
level_prices = [lv[0] for lv in levels if lv[0] > tp1]
|
||||
tp2 = min(level_prices) if level_prices else entry_price + 2.5 * atr_val
|
||||
|
||||
# TP3: 2x TP1 distance or 4x ATR (runner)
|
||||
tp1_dist = tp1 - entry_price
|
||||
tp3 = entry_price + max(2.0 * tp1_dist, 4.0 * atr_val)
|
||||
|
||||
else: # SHORT
|
||||
sl_mask = swing_lows(window, lookback=5)
|
||||
sl_prices = window.loc[sl_mask, "low"]
|
||||
below = sl_prices[sl_prices < entry_price].sort_values(ascending=False)
|
||||
tp1 = below.iloc[0] if len(below) > 0 else entry_price - 1.5 * atr_val
|
||||
|
||||
levels = identify_key_levels(window, lookback=5, min_touches=2)
|
||||
level_prices = [lv[0] for lv in levels if lv[0] < tp1]
|
||||
tp2 = max(level_prices) if level_prices else entry_price - 2.5 * atr_val
|
||||
|
||||
tp1_dist = entry_price - tp1
|
||||
tp3 = entry_price - max(2.0 * tp1_dist, 4.0 * atr_val)
|
||||
|
||||
# Ensure TP ordering makes sense
|
||||
if direction == "LONG":
|
||||
tp1 = max(tp1, entry_price + 0.5 * atr_val)
|
||||
tp2 = max(tp2, tp1 + 0.3 * atr_val)
|
||||
tp3 = max(tp3, tp2 + 0.3 * atr_val)
|
||||
else:
|
||||
tp1 = min(tp1, entry_price - 0.5 * atr_val)
|
||||
tp2 = min(tp2, tp1 - 0.3 * atr_val)
|
||||
tp3 = min(tp3, tp2 - 0.3 * atr_val)
|
||||
|
||||
return tp1, tp2, tp3
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reversal Pattern Detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_reversal_pattern(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series, direction: str) -> str:
|
||||
"""
|
||||
Check for reversal patterns at the retest candle.
|
||||
Returns pattern name ('engulfing', 'pin_bar', 'strong_close') or None.
|
||||
"""
|
||||
o, h, l, c = current["open"], current["high"], current["low"], current["close"]
|
||||
body = abs(c - o)
|
||||
full_range = h - l
|
||||
if full_range <= 0:
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
# 1. Bullish engulfing
|
||||
if is_bullish_engulfing(data, idx):
|
||||
return "engulfing"
|
||||
# 2. Bullish pin bar: lower wick >= 2x body AND close in upper 25%
|
||||
lower_wick = min(o, c) - l
|
||||
if body > 0 and lower_wick >= 2 * body and c >= l + 0.75 * full_range:
|
||||
return "pin_bar"
|
||||
# 3. Strong bullish close: body > 60% of range AND close > open
|
||||
if body > 0.60 * full_range and c > o:
|
||||
return "strong_close"
|
||||
else: # SHORT
|
||||
# 1. Bearish engulfing
|
||||
if is_bearish_engulfing(data, idx):
|
||||
return "engulfing"
|
||||
# 2. Bearish pin bar: upper wick >= 2x body AND close in lower 25%
|
||||
upper_wick = h - max(o, c)
|
||||
if body > 0 and upper_wick >= 2 * body and c <= l + 0.25 * full_range:
|
||||
return "pin_bar"
|
||||
# 3. Strong bearish close: body > 60% of range AND close < open
|
||||
if body > 0.60 * full_range and c < o:
|
||||
return "strong_close"
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main Signal Check
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
@@ -35,143 +377,95 @@ class S1_MA_Breakout(BaseStrategy):
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter: only London + NY (08:00-17:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
htf = self.htf_data
|
||||
if htf is None or len(htf) < 50:
|
||||
return None
|
||||
|
||||
current_ts = current.name
|
||||
|
||||
# Efficient HTF cutoff via searchsorted
|
||||
n_valid = self._htf_cutoff(htf, current_ts)
|
||||
if n_valid < 50:
|
||||
return None
|
||||
|
||||
# Detect trendlines and update state machine (always, for tracking)
|
||||
self._detect_trendlines(htf, n_valid)
|
||||
self._update_state_machine(htf, n_valid)
|
||||
|
||||
# Session filter: London + NY overlap (08:00-16:00 UTC)
|
||||
hour = current_ts.hour if hasattr(current_ts, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
ema_200 = current.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_50, ema_100, ema_200]):
|
||||
# Only generate signals in RETEST phase
|
||||
if self._state["phase"] != "RETEST":
|
||||
return None
|
||||
|
||||
direction = self._state["direction"]
|
||||
tl = self._state["trendline"]
|
||||
|
||||
# Project trendline price at current H1 bar
|
||||
current_htf_idx = n_valid - 1
|
||||
tl_price = self._project_tl_at_htf_bar(tl, current_htf_idx)
|
||||
|
||||
close = current["close"]
|
||||
open_p = current["open"]
|
||||
prev = data.iloc[idx - 1]
|
||||
prev_close = prev["close"]
|
||||
|
||||
# Candle body filter: body must be > 30% of range (no dojis)
|
||||
body = abs(close - open_p)
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0 or body / full_range < 0.3:
|
||||
# M15 close within 1.5x ATR of projected trendline
|
||||
dist_to_tl = abs(close - tl_price)
|
||||
if dist_to_tl > 1.5 * atr_val:
|
||||
return None
|
||||
|
||||
# LONG setup
|
||||
if ema_50 > ema_100 > ema_200:
|
||||
# HTF filter
|
||||
if htf_row is not None:
|
||||
htf_sma200 = htf_row.get("sma_200", np.nan)
|
||||
if not np.isnan(htf_sma200) and htf_row.get("close", 0) <= htf_sma200:
|
||||
return None
|
||||
# M15 reversal pattern confirmation (engulfing, pin bar, or strong close)
|
||||
entry_pattern = self._detect_reversal_pattern(data, idx, current, direction)
|
||||
if entry_pattern is None:
|
||||
return None
|
||||
|
||||
# Pullback to EMA 50 zone (within 1.0x ATR - tightened from 1.5x)
|
||||
dist_to_ema50 = close - ema_50
|
||||
if dist_to_ema50 < 0 or dist_to_ema50 > 1.0 * atr_val:
|
||||
# EMA 50 alignment
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
if np.isnan(ema_50):
|
||||
return None
|
||||
if direction == "LONG" and close <= ema_50:
|
||||
return None
|
||||
if direction == "SHORT" and close >= ema_50:
|
||||
return None
|
||||
|
||||
# Confluence scoring
|
||||
confluence = self._calc_confluence(data, idx, current, direction, tl)
|
||||
if confluence < 2:
|
||||
return None
|
||||
|
||||
# SL: behind the trendline (0.5x ATR past TL)
|
||||
if direction == "LONG":
|
||||
sl = tl_price - 0.5 * atr_val
|
||||
# Validate SL is below entry (TL may have drifted above price)
|
||||
if sl >= close:
|
||||
return None
|
||||
else:
|
||||
sl = tl_price + 0.5 * atr_val
|
||||
if sl <= close:
|
||||
return None
|
||||
|
||||
# Bullish confirmation candle
|
||||
if not (close > open_p and close > prev_close):
|
||||
return None
|
||||
# TP levels: structure-based from H1 data
|
||||
tp1, tp2, tp3 = self._find_structure_tp(
|
||||
htf, n_valid, direction, close, atr_val
|
||||
)
|
||||
|
||||
# Not too far from EMAs (avoid chasing)
|
||||
if close - ema_200 > 5 * atr_val:
|
||||
return None
|
||||
# Reset state after generating signal
|
||||
self._reset_state()
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, "LONG")
|
||||
|
||||
# Require minimum confluence of 2
|
||||
if confluence < 2:
|
||||
return None
|
||||
|
||||
sl = current["low"] - 0.5 * atr_val
|
||||
tp1 = close + 1.5 * atr_val
|
||||
tp2 = close + 2.5 * atr_val
|
||||
tp3 = close + 4.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 200,
|
||||
}
|
||||
|
||||
# SHORT setup
|
||||
if ema_50 < ema_100 < ema_200:
|
||||
if htf_row is not None:
|
||||
htf_sma200 = htf_row.get("sma_200", np.nan)
|
||||
if not np.isnan(htf_sma200) and htf_row.get("close", 0) >= htf_sma200:
|
||||
return None
|
||||
|
||||
dist_to_ema50 = ema_50 - close
|
||||
if dist_to_ema50 < 0 or dist_to_ema50 > 1.0 * atr_val:
|
||||
return None
|
||||
|
||||
if not (close < open_p and close < prev_close):
|
||||
return None
|
||||
|
||||
if ema_200 - close > 5 * atr_val:
|
||||
return None
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, "SHORT")
|
||||
|
||||
if confluence < 2:
|
||||
return None
|
||||
|
||||
sl = current["high"] + 0.5 * atr_val
|
||||
tp1 = close - 1.5 * atr_val
|
||||
tp2 = close - 2.5 * atr_val
|
||||
tp3 = close - 4.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 200,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _calc_confluence(self, data, idx, current, direction):
|
||||
confluence = 0
|
||||
|
||||
# Volume above average
|
||||
if "volume" in current.index:
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and current["volume"] > vol_avg:
|
||||
confluence += 1
|
||||
|
||||
# RSI between 40-60
|
||||
rsi = current.get("rsi_14", 50)
|
||||
if 40 <= rsi <= 60:
|
||||
confluence += 1
|
||||
|
||||
# MACD histogram confirmation
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
prev_macd_h = data.iloc[idx - 1].get("macd_hist", 0)
|
||||
if direction == "LONG" and macd_h > 0 and macd_h > prev_macd_h:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and macd_h < 0 and macd_h < prev_macd_h:
|
||||
confluence += 1
|
||||
|
||||
# ADX > 20
|
||||
if current.get("adx_14", 0) > 20:
|
||||
confluence += 1
|
||||
|
||||
# VWAP alignment
|
||||
vwap = current.get("session_vwap", 0)
|
||||
if vwap:
|
||||
if direction == "LONG" and current["close"] > vwap:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and current["close"] < vwap:
|
||||
confluence += 1
|
||||
|
||||
return min(confluence, 5)
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": entry_pattern,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 200,
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ Strategy 3: Key Level Momentum Breakout.
|
||||
Entry TF: H1. Key levels identified from swing point clusters.
|
||||
|
||||
Entry conditions (LONG):
|
||||
- H1 candle closes above a key level (horizontal S/R with 2+ touches)
|
||||
- H1 candle closes above a key level (horizontal S/R with 3+ touches)
|
||||
- Volume spike: current volume > 1.5x 20-bar average
|
||||
- Strong close: candle body > 50% of range (conviction candle)
|
||||
- MACD histogram same sign as direction
|
||||
- ADX > 15
|
||||
- Candle body > 30% of range (conviction candle)
|
||||
- ADX > 20 (trending market)
|
||||
- Session: London + NY overlap (08:00-16:00 UTC)
|
||||
|
||||
SL: Back inside key level + 1x ATR buffer
|
||||
SL: Back inside key level — level_price -/+ 0.5x ATR
|
||||
TP1: 1.5x ATR, TP2: 2.5x ATR, TP3: 4x ATR
|
||||
"""
|
||||
from typing import Optional
|
||||
@@ -24,6 +26,7 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
name = "S3_Key_Level_Breakout"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._cached_levels = None
|
||||
self._cache_idx = -1
|
||||
|
||||
@@ -33,44 +36,51 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
if idx < 100:
|
||||
return None
|
||||
|
||||
# Session filter: London/NY only (08:00-17:00 UTC)
|
||||
# Session filter: London + NY overlap (08:00-16:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# ADX filter: require trending market
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if adx_val < 20:
|
||||
return None
|
||||
|
||||
# Strong close: candle body > 50% of range
|
||||
close = current["close"]
|
||||
body = abs(close - current["open"])
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0 or body / full_range < 0.50:
|
||||
return None
|
||||
|
||||
# Volume spike: current volume > 1.5x 20-bar average
|
||||
vol = current.get("volume", 0)
|
||||
if vol > 0 and idx >= 20:
|
||||
vol_avg = data["volume"].iloc[idx - 20:idx].mean()
|
||||
if vol_avg > 0 and vol < 1.5 * vol_avg:
|
||||
return None
|
||||
|
||||
prev_close = data.iloc[idx - 1]["close"]
|
||||
|
||||
# MACD
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
|
||||
# Recalculate key levels every 20 bars using larger lookback
|
||||
if self._cached_levels is None or idx - self._cache_idx >= 20:
|
||||
start = max(0, idx - 1000)
|
||||
window = data.iloc[start:idx] # exclude current bar
|
||||
self._cached_levels = identify_key_levels(
|
||||
window, lookback=5, tolerance_atr_mult=0.75, min_touches=2
|
||||
window, lookback=5, tolerance_atr_mult=0.75, min_touches=3
|
||||
)
|
||||
self._cache_idx = idx
|
||||
|
||||
if not self._cached_levels:
|
||||
return None
|
||||
|
||||
close = current["close"]
|
||||
prev_close = data.iloc[idx - 1]["close"]
|
||||
|
||||
# Candle body filter
|
||||
body = abs(close - current["open"])
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0 or body / full_range < 0.3:
|
||||
return None
|
||||
|
||||
# MACD
|
||||
macd_h = current.get("macd_hist", 0)
|
||||
|
||||
# ADX
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if adx_val < 15:
|
||||
return None
|
||||
|
||||
for level_price, touch_count in self._cached_levels:
|
||||
tolerance = 0.3 * atr_val
|
||||
|
||||
@@ -85,9 +95,10 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
if ema_50 and ema_200 and ema_50 <= ema_200:
|
||||
continue
|
||||
|
||||
confluence = self._calc_confluence(current, data, idx, "LONG", touch_count)
|
||||
confluence = self._calc_confluence(current, data, idx,
|
||||
"LONG", touch_count, vol)
|
||||
|
||||
sl = level_price - 0.3 * atr_val # Tight SL just inside key level
|
||||
sl = level_price - 0.5 * atr_val
|
||||
tp1 = close + 1.5 * atr_val
|
||||
tp2 = close + 2.5 * atr_val
|
||||
tp3 = close + 4.0 * atr_val
|
||||
@@ -96,6 +107,7 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "key_level_break",
|
||||
"tp_splits": (0.40, 0.40, 0.20),
|
||||
"trail_atr_mult": 2.0,
|
||||
"max_bars": 150,
|
||||
@@ -112,9 +124,10 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
if ema_50 and ema_200 and ema_50 >= ema_200:
|
||||
continue
|
||||
|
||||
confluence = self._calc_confluence(current, data, idx, "SHORT", touch_count)
|
||||
confluence = self._calc_confluence(current, data, idx,
|
||||
"SHORT", touch_count, vol)
|
||||
|
||||
sl = level_price + 0.3 * atr_val # Tight SL just inside key level
|
||||
sl = level_price + 0.5 * atr_val
|
||||
tp1 = close - 1.5 * atr_val
|
||||
tp2 = close - 2.5 * atr_val
|
||||
tp3 = close - 4.0 * atr_val
|
||||
@@ -123,6 +136,7 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "key_level_break",
|
||||
"tp_splits": (0.40, 0.40, 0.20),
|
||||
"trail_atr_mult": 2.0,
|
||||
"max_bars": 150,
|
||||
@@ -130,7 +144,7 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
|
||||
return None
|
||||
|
||||
def _calc_confluence(self, current, data, idx, direction, touch_count):
|
||||
def _calc_confluence(self, current, data, idx, direction, touch_count, vol):
|
||||
confluence = 1 # breakout confirmed
|
||||
|
||||
# More touches = stronger level
|
||||
@@ -139,18 +153,16 @@ class S3_KeyLevel_Breakout(BaseStrategy):
|
||||
if touch_count >= 5:
|
||||
confluence += 1
|
||||
|
||||
# Volume spike strength (>2x avg = extra point)
|
||||
if vol > 0 and idx >= 20:
|
||||
vol_avg = data["volume"].iloc[idx - 20:idx].mean()
|
||||
if vol_avg > 0 and vol > 2.0 * vol_avg:
|
||||
confluence += 1
|
||||
|
||||
rsi = current.get("rsi_14", 50)
|
||||
if direction == "LONG" and 50 < rsi < 75:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and 25 < rsi < 50:
|
||||
confluence += 1
|
||||
|
||||
ema_50 = current.get("ema_50", 0)
|
||||
ema_200 = current.get("ema_200", 0)
|
||||
if ema_50 and ema_200:
|
||||
if direction == "LONG" and ema_50 > ema_200:
|
||||
confluence += 1
|
||||
elif direction == "SHORT" and ema_50 < ema_200:
|
||||
confluence += 1
|
||||
|
||||
return min(confluence, 5)
|
||||
|
||||
@@ -9,10 +9,11 @@ M15 Entry (LONG):
|
||||
- EMA ribbon compressed (EMAs within 1.0x ATR)
|
||||
- Ribbon re-expanding (current width > prev width)
|
||||
- Stochastic turning from oversold
|
||||
- Session filter: London/NY (08:00-17:00 UTC)
|
||||
- Session filter: London/NY (08:00-16:00 UTC)
|
||||
|
||||
SL: Below compression low - 0.5x ATR
|
||||
TP1: 1x ATR, TP2: 1.5x ATR, TP3: 2.5x ATR
|
||||
SL: Below compression zone low/high - 0.5x ATR (capped at 1.5x ATR from entry)
|
||||
TP1: 2x ATR, TP2: 3x ATR, TP3: 5x ATR
|
||||
Min RR: 1.0:1 at entry (TP1 dist >= SL dist)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
@@ -30,9 +31,9 @@ class S4_EMA_Ribbon(BaseStrategy):
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter
|
||||
# Session filter: London + NY overlap (08:00-16:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
@@ -64,7 +65,7 @@ class S4_EMA_Ribbon(BaseStrategy):
|
||||
return None
|
||||
|
||||
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
|
||||
compression_threshold = 1.0 * atr_val # relaxed from 0.5x
|
||||
compression_threshold = 1.0 * atr_val
|
||||
|
||||
# Check for recent compression (look back 5-20 bars)
|
||||
was_compressed = False
|
||||
@@ -106,20 +107,28 @@ class S4_EMA_Ribbon(BaseStrategy):
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, atr_val, "LONG")
|
||||
|
||||
# Use tighter SL: max of (compression_low, close - 0.8*ATR)
|
||||
sl_compression = compression_low - 0.3 * atr_val
|
||||
sl_atr = current["close"] - 0.8 * atr_val
|
||||
sl = max(sl_compression, sl_atr)
|
||||
tp1 = current["close"] + 1.0 * atr_val
|
||||
tp2 = current["close"] + 1.5 * atr_val
|
||||
tp3 = current["close"] + 2.5 * atr_val
|
||||
# SL: below compression zone low with 0.5x ATR buffer, capped at 1.5 ATR
|
||||
sl_natural = compression_low - 0.5 * atr_val
|
||||
sl_max = current["close"] - 1.5 * atr_val
|
||||
sl = max(sl_natural, sl_max)
|
||||
|
||||
tp1 = current["close"] + 2.0 * atr_val
|
||||
tp2 = current["close"] + 3.0 * atr_val
|
||||
tp3 = current["close"] + 5.0 * atr_val
|
||||
|
||||
# Min 1:1 RR gate
|
||||
sl_dist = current["close"] - sl
|
||||
tp1_dist = tp1 - current["close"]
|
||||
if sl_dist <= 0 or tp1_dist / sl_dist < 1.0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.0,
|
||||
"entry_pattern": "ribbon_expansion",
|
||||
"tp_splits": (0.40, 0.30, 0.30),
|
||||
"trail_atr_mult": 2.5,
|
||||
"max_bars": 60,
|
||||
}
|
||||
|
||||
@@ -131,19 +140,28 @@ class S4_EMA_Ribbon(BaseStrategy):
|
||||
|
||||
confluence = self._calc_confluence(data, idx, current, atr_val, "SHORT")
|
||||
|
||||
sl_compression = compression_high + 0.3 * atr_val
|
||||
sl_atr = current["close"] + 0.8 * atr_val
|
||||
sl = min(sl_compression, sl_atr)
|
||||
tp1 = current["close"] - 1.0 * atr_val
|
||||
tp2 = current["close"] - 1.5 * atr_val
|
||||
tp3 = current["close"] - 2.5 * atr_val
|
||||
# SL: above compression zone high with 0.5x ATR buffer, capped at 1.5 ATR
|
||||
sl_natural = compression_high + 0.5 * atr_val
|
||||
sl_max = current["close"] + 1.5 * atr_val
|
||||
sl = min(sl_natural, sl_max)
|
||||
|
||||
tp1 = current["close"] - 2.0 * atr_val
|
||||
tp2 = current["close"] - 3.0 * atr_val
|
||||
tp3 = current["close"] - 5.0 * atr_val
|
||||
|
||||
# Min 1:1 RR gate
|
||||
sl_dist = sl - current["close"]
|
||||
tp1_dist = current["close"] - tp1
|
||||
if sl_dist <= 0 or tp1_dist / sl_dist < 1.0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"tp_splits": (0.50, 0.30, 0.20),
|
||||
"trail_atr_mult": 1.0,
|
||||
"entry_pattern": "ribbon_expansion",
|
||||
"tp_splits": (0.40, 0.30, 0.30),
|
||||
"trail_atr_mult": 2.5,
|
||||
"max_bars": 60,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Strategy S4-D: EMA Ribbon — Volume + ADX Gating.
|
||||
|
||||
Entry: Current S4 ribbon compression -> expansion logic PLUS:
|
||||
- Volume > 2.0x 20-period average
|
||||
- ADX_14 > 30
|
||||
- ADX rising vs 5 bars ago
|
||||
- ADX was < 35 at some point in last 10 bars (not exhausted)
|
||||
- Distance between 15min 8 EMA and 55 EMA > 1.5% of current price
|
||||
|
||||
Exit:
|
||||
- SL: 2.0 ATR
|
||||
- TP: 3.0 ATR (100% close)
|
||||
- No partials, no BE moves
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S4D_EMA_Ribbon(BaseStrategy):
|
||||
strategy_id = 4
|
||||
name = "S4D_Volume_ADX_Gating"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter: 08:00-16:00 UTC
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
# H1 EMA stack check
|
||||
htf_ema20 = htf_row.get("ema_20", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema100 = htf_row.get("ema_100", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_ema20, htf_ema50, htf_ema100, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_stack = htf_ema20 > htf_ema50 > htf_ema100 > htf_ema200
|
||||
short_stack = htf_ema20 < htf_ema50 < htf_ema100 < htf_ema200
|
||||
if not long_stack and not short_stack:
|
||||
return None
|
||||
|
||||
# M15 ribbon EMAs
|
||||
ema_20 = current.get("ema_20", np.nan)
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_20, ema_50, ema_100]):
|
||||
return None
|
||||
|
||||
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
|
||||
compression_threshold = 1.0 * atr_val
|
||||
|
||||
# Check for recent compression
|
||||
was_compressed = False
|
||||
min_compression_width = float('inf')
|
||||
for j in range(max(0, idx - 20), idx):
|
||||
bar = data.iloc[j]
|
||||
e20 = bar.get("ema_20", np.nan)
|
||||
e50 = bar.get("ema_50", np.nan)
|
||||
e100 = bar.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [e20, e50, e100]):
|
||||
continue
|
||||
w = max(e20, e50, e100) - min(e20, e50, e100)
|
||||
if w <= compression_threshold:
|
||||
was_compressed = True
|
||||
min_compression_width = min(min_compression_width, w)
|
||||
|
||||
if not was_compressed:
|
||||
return None
|
||||
|
||||
# Ribbon expanding
|
||||
if ribbon_width <= min_compression_width * 1.2:
|
||||
return None
|
||||
|
||||
# Determine direction from ribbon expansion
|
||||
if long_stack:
|
||||
if not (ema_20 >= ema_50):
|
||||
return None
|
||||
direction = "LONG"
|
||||
elif short_stack:
|
||||
if not (ema_20 <= ema_50):
|
||||
return None
|
||||
direction = "SHORT"
|
||||
else:
|
||||
return None
|
||||
|
||||
# ------- NEW S4-D FILTERS -------
|
||||
|
||||
# Volume > 2.0x 20-period average
|
||||
if "volume" not in current.index:
|
||||
return None
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg <= 0 or vol <= 2.0 * vol_avg:
|
||||
return None
|
||||
|
||||
# ADX_14 > 30
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if np.isnan(adx_val) or adx_val <= 30:
|
||||
return None
|
||||
|
||||
# ADX rising vs 5 bars ago
|
||||
if idx < 5:
|
||||
return None
|
||||
adx_5_ago = data.iloc[idx - 5].get("adx_14", 0)
|
||||
if np.isnan(adx_5_ago) or adx_val <= adx_5_ago:
|
||||
return None
|
||||
|
||||
# ADX was < 35 at some point in last 10 bars (not exhausted)
|
||||
adx_was_low = False
|
||||
for j in range(max(0, idx - 10), idx):
|
||||
bar_adx = data.iloc[j].get("adx_14", 0)
|
||||
if not np.isnan(bar_adx) and bar_adx < 35:
|
||||
adx_was_low = True
|
||||
break
|
||||
if not adx_was_low:
|
||||
return None
|
||||
|
||||
# Distance between 15min 8 EMA and 55 EMA > 1.5% of current price
|
||||
ema_8 = current.get("ema_8", np.nan)
|
||||
ema_55 = current.get("ema_55", np.nan)
|
||||
if np.isnan(ema_8) or np.isnan(ema_55):
|
||||
return None
|
||||
price = current["close"]
|
||||
if abs(ema_8 - ema_55) <= 0.015 * price:
|
||||
return None
|
||||
|
||||
# ------- EXIT LEVELS -------
|
||||
if direction == "LONG":
|
||||
sl = price - 2.0 * atr_val
|
||||
tp1 = price + 3.0 * atr_val
|
||||
else:
|
||||
sl = price + 2.0 * atr_val
|
||||
tp1 = price - 3.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp1, # same as tp1 — single target
|
||||
"tp3": tp1,
|
||||
"confluence": 3,
|
||||
"entry_pattern": "ribbon_vol_adx",
|
||||
"tp_splits": (1.0, 0.0, 0.0), # 100% close at TP1
|
||||
"trail_atr_mult": 0, # no trailing
|
||||
"max_bars": 60,
|
||||
"no_breakeven": True,
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Strategy S4-E: EMA Ribbon — Compression Quality + Stochastic.
|
||||
|
||||
Entry: Current S4 ribbon logic PLUS:
|
||||
- All 5 EMAs (8, 13, 21, 34, 55) were within 0.3% of each other
|
||||
in at least 1 of last 10 bars (true compression)
|
||||
- Now expanding (current distance between 8 and 55 > 0.5% of price)
|
||||
- Stochastic_14 %K was < 20 in last 5 bars (LONG) AND current %K > %D
|
||||
- Stochastic_14 %K was > 80 in last 5 bars (SHORT) AND current %K < %D
|
||||
- MACD histogram expanding in trade direction
|
||||
- Volume > 1.2x average
|
||||
|
||||
Exit: Same as S4-D (SL 2.0 ATR, TP 3.0 ATR, 100% close, no partials)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S4E_EMA_Ribbon(BaseStrategy):
|
||||
strategy_id = 4
|
||||
name = "S4E_Compression_Quality"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
# H1 EMA stack
|
||||
htf_ema20 = htf_row.get("ema_20", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema100 = htf_row.get("ema_100", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_ema20, htf_ema50, htf_ema100, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_stack = htf_ema20 > htf_ema50 > htf_ema100 > htf_ema200
|
||||
short_stack = htf_ema20 < htf_ema50 < htf_ema100 < htf_ema200
|
||||
if not long_stack and not short_stack:
|
||||
return None
|
||||
|
||||
direction = "LONG" if long_stack else "SHORT"
|
||||
|
||||
# ------- TRUE COMPRESSION: all 5 EMAs within 0.3% in last 10 bars -------
|
||||
had_true_compression = False
|
||||
for j in range(max(0, idx - 10), idx):
|
||||
bar = data.iloc[j]
|
||||
emas = []
|
||||
for p in [8, 13, 21, 34, 55]:
|
||||
v = bar.get(f"ema_{p}", np.nan)
|
||||
if np.isnan(v):
|
||||
break
|
||||
emas.append(v)
|
||||
if len(emas) < 5:
|
||||
continue
|
||||
spread = max(emas) - min(emas)
|
||||
mid = np.mean(emas)
|
||||
if mid > 0 and spread / mid < 0.003: # within 0.3%
|
||||
had_true_compression = True
|
||||
break
|
||||
|
||||
if not had_true_compression:
|
||||
return None
|
||||
|
||||
# ------- NOW EXPANDING: 8 EMA vs 55 EMA > 0.5% of price -------
|
||||
ema_8 = current.get("ema_8", np.nan)
|
||||
ema_55 = current.get("ema_55", np.nan)
|
||||
if np.isnan(ema_8) or np.isnan(ema_55):
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
if abs(ema_8 - ema_55) <= 0.005 * price:
|
||||
return None
|
||||
|
||||
# Direction consistency: 8 EMA must be on correct side of 55 EMA
|
||||
if direction == "LONG" and ema_8 <= ema_55:
|
||||
return None
|
||||
if direction == "SHORT" and ema_8 >= ema_55:
|
||||
return None
|
||||
|
||||
# ------- STOCHASTIC 14 FILTER -------
|
||||
stoch_k = current.get("stoch_k_14", np.nan)
|
||||
stoch_d = current.get("stoch_d_14", np.nan)
|
||||
if np.isnan(stoch_k) or np.isnan(stoch_d):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
# %K was < 20 in last 5 bars
|
||||
stoch_was_oversold = False
|
||||
for j in range(max(0, idx - 5), idx):
|
||||
sk = data.iloc[j].get("stoch_k_14", 50)
|
||||
if not np.isnan(sk) and sk < 20:
|
||||
stoch_was_oversold = True
|
||||
break
|
||||
if not stoch_was_oversold:
|
||||
return None
|
||||
# Current %K > %D
|
||||
if stoch_k <= stoch_d:
|
||||
return None
|
||||
else:
|
||||
# %K was > 80 in last 5 bars
|
||||
stoch_was_overbought = False
|
||||
for j in range(max(0, idx - 5), idx):
|
||||
sk = data.iloc[j].get("stoch_k_14", 50)
|
||||
if not np.isnan(sk) and sk > 80:
|
||||
stoch_was_overbought = True
|
||||
break
|
||||
if not stoch_was_overbought:
|
||||
return None
|
||||
# Current %K < %D
|
||||
if stoch_k >= stoch_d:
|
||||
return None
|
||||
|
||||
# ------- MACD HISTOGRAM EXPANDING -------
|
||||
if idx < 2:
|
||||
return None
|
||||
macd_curr = current.get("macd_hist", 0)
|
||||
macd_1 = data.iloc[idx - 1].get("macd_hist", 0)
|
||||
macd_2 = data.iloc[idx - 2].get("macd_hist", 0)
|
||||
|
||||
if direction == "LONG":
|
||||
if not (macd_curr > macd_1 and macd_curr > macd_2):
|
||||
return None
|
||||
else:
|
||||
if not (macd_curr < macd_1 and macd_curr < macd_2):
|
||||
return None
|
||||
|
||||
# ------- VOLUME > 1.2x average -------
|
||||
if "volume" not in current.index:
|
||||
return None
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg <= 0 or vol <= 1.2 * vol_avg:
|
||||
return None
|
||||
|
||||
# ------- EXIT LEVELS -------
|
||||
if direction == "LONG":
|
||||
sl = price - 2.0 * atr_val
|
||||
tp1 = price + 3.0 * atr_val
|
||||
else:
|
||||
sl = price + 2.0 * atr_val
|
||||
tp1 = price - 3.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp1,
|
||||
"tp3": tp1,
|
||||
"confluence": 3,
|
||||
"entry_pattern": "ribbon_compression_quality",
|
||||
"tp_splits": (1.0, 0.0, 0.0),
|
||||
"trail_atr_mult": 0,
|
||||
"max_bars": 60,
|
||||
"no_breakeven": True,
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Strategy S4-F: EMA Ribbon — Trend Context Filter.
|
||||
|
||||
Entry: Current S4 ribbon logic PLUS:
|
||||
- 1H trend for LONG: 1H close > 1H 200 EMA AND 1H 50 EMA > 1H 200 EMA
|
||||
- 1H trend for SHORT: 1H close < 1H 200 EMA AND 1H 50 EMA < 1H 200 EMA
|
||||
- Price within 1.5 ATR of 1H 50 EMA
|
||||
- 15min EMA stacking for LONG: 8 EMA > 13 EMA AND 13 EMA > 21 EMA
|
||||
- 15min EMA stacking for SHORT: 8 EMA < 13 EMA AND 13 EMA < 21 EMA
|
||||
- Volume > 1.2x average
|
||||
|
||||
Exit: Same as S4-D (SL 2.0 ATR, TP 3.0 ATR, 100% close, no partials)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S4F_EMA_Ribbon(BaseStrategy):
|
||||
strategy_id = 4
|
||||
name = "S4F_Trend_Context"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
# ------- ORIGINAL H1 EMA STACK (20 > 50 > 100 > 200) -------
|
||||
htf_ema20 = htf_row.get("ema_20", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema100 = htf_row.get("ema_100", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_ema20, htf_ema50, htf_ema100, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_stack = htf_ema20 > htf_ema50 > htf_ema100 > htf_ema200
|
||||
short_stack = htf_ema20 < htf_ema50 < htf_ema100 < htf_ema200
|
||||
if not long_stack and not short_stack:
|
||||
return None
|
||||
|
||||
# ------- NEW 1H TREND FILTER -------
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if np.isnan(htf_close):
|
||||
return None
|
||||
|
||||
if long_stack:
|
||||
if not (htf_close > htf_ema200 and htf_ema50 > htf_ema200):
|
||||
return None
|
||||
direction = "LONG"
|
||||
else:
|
||||
if not (htf_close < htf_ema200 and htf_ema50 < htf_ema200):
|
||||
return None
|
||||
direction = "SHORT"
|
||||
|
||||
# ------- PRICE WITHIN 1.5 ATR OF 1H 50 EMA -------
|
||||
price = current["close"]
|
||||
if abs(price - htf_ema50) > 1.5 * atr_val:
|
||||
return None
|
||||
|
||||
# ------- M15 RIBBON COMPRESSION -> EXPANSION -------
|
||||
ema_20 = current.get("ema_20", np.nan)
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_20, ema_50, ema_100]):
|
||||
return None
|
||||
|
||||
ribbon_width = max(ema_20, ema_50, ema_100) - min(ema_20, ema_50, ema_100)
|
||||
compression_threshold = 1.0 * atr_val
|
||||
|
||||
was_compressed = False
|
||||
min_compression_width = float('inf')
|
||||
for j in range(max(0, idx - 20), idx):
|
||||
bar = data.iloc[j]
|
||||
e20 = bar.get("ema_20", np.nan)
|
||||
e50 = bar.get("ema_50", np.nan)
|
||||
e100 = bar.get("ema_100", np.nan)
|
||||
if any(np.isnan(v) for v in [e20, e50, e100]):
|
||||
continue
|
||||
w = max(e20, e50, e100) - min(e20, e50, e100)
|
||||
if w <= compression_threshold:
|
||||
was_compressed = True
|
||||
min_compression_width = min(min_compression_width, w)
|
||||
|
||||
if not was_compressed:
|
||||
return None
|
||||
|
||||
if ribbon_width <= min_compression_width * 1.2:
|
||||
return None
|
||||
|
||||
# ------- 15MIN EMA STACKING: 8 > 13 > 21 (LONG) or reversed -------
|
||||
ema_8 = current.get("ema_8", np.nan)
|
||||
ema_13 = current.get("ema_13", np.nan)
|
||||
ema_21 = current.get("ema_21", np.nan)
|
||||
if any(np.isnan(v) for v in [ema_8, ema_13, ema_21]):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
if not (ema_8 > ema_13 and ema_13 > ema_21):
|
||||
return None
|
||||
else:
|
||||
if not (ema_8 < ema_13 and ema_13 < ema_21):
|
||||
return None
|
||||
|
||||
# ------- VOLUME > 1.2x average -------
|
||||
if "volume" not in current.index:
|
||||
return None
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg <= 0 or vol <= 1.2 * vol_avg:
|
||||
return None
|
||||
|
||||
# ------- EXIT LEVELS -------
|
||||
if direction == "LONG":
|
||||
sl = price - 2.0 * atr_val
|
||||
tp1 = price + 3.0 * atr_val
|
||||
else:
|
||||
sl = price + 2.0 * atr_val
|
||||
tp1 = price - 3.0 * atr_val
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp1,
|
||||
"tp3": tp1,
|
||||
"confluence": 3,
|
||||
"entry_pattern": "ribbon_trend_context",
|
||||
"tp_splits": (1.0, 0.0, 0.0),
|
||||
"trail_atr_mult": 0,
|
||||
"max_bars": 60,
|
||||
"no_breakeven": True,
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Strategy S4-F-v2: EMA Ribbon — Trend Context Quick Tune.
|
||||
|
||||
3-timeframe strategy: M5 entry, M15 ribbon/ATR, H1 trend filter.
|
||||
|
||||
Entry Requirements (ALL must be true):
|
||||
1H Trend Filter:
|
||||
- LONG: 1H close > 1H 200 EMA AND 1H 50 EMA > 1H 200 EMA
|
||||
- SHORT: 1H close < 1H 200 EMA AND 1H 50 EMA < 1H 200 EMA
|
||||
|
||||
1H Momentum:
|
||||
- 1H ADX_14 > 28
|
||||
- 1H ADX rising over last 5 bars
|
||||
|
||||
15min EMA Ribbon:
|
||||
- LONG: 15min 50 EMA > 15min 100 EMA
|
||||
- SHORT: 15min 50 EMA < 15min 100 EMA
|
||||
- Was compressed within last 10 bars (all 5 EMAs within 0.4% of each other)
|
||||
- Current distance between 15min 8 EMA and 55 EMA > 0.8% of price
|
||||
|
||||
15min Volume:
|
||||
- 15min current volume > 2.0x 20-period average
|
||||
|
||||
5min Timing:
|
||||
- LONG: 5min 8 EMA > 5min 13 EMA > 5min 21 EMA
|
||||
- SHORT: 5min 8 EMA < 5min 13 EMA < 5min 21 EMA
|
||||
- LONG: 5min Stochastic_14 %K was < 20 in last 5 bars AND current %K > %D
|
||||
- SHORT: 5min Stochastic_14 %K was > 80 in last 5 bars AND current %K < %D
|
||||
|
||||
Price Distance:
|
||||
- Price within 1.5 ATR of 1H 50 EMA (using 15min ATR)
|
||||
|
||||
Entry: Close of 5min bar when all conditions met.
|
||||
|
||||
Exit:
|
||||
- SL: 2.0 ATR (15min) from entry
|
||||
- TP: 3.0 ATR (15min) from entry, 100% close
|
||||
- No partials, no BE moves
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S4Fv2_EMA_Ribbon(BaseStrategy):
|
||||
strategy_id = 4
|
||||
name = "S4Fv2_Trend_Context_v2"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.m15_data = None # Set externally by runner
|
||||
|
||||
def _get_m15_row(self, timestamp: pd.Timestamp) -> Optional[pd.Series]:
|
||||
"""Get most recent FULLY CLOSED M15 candle before timestamp."""
|
||||
if self.m15_data is None:
|
||||
return None
|
||||
valid = self.m15_data[self.m15_data.index < timestamp]
|
||||
if len(valid) == 0:
|
||||
return None
|
||||
return valid.iloc[-1]
|
||||
|
||||
def _get_m15_lookback(self, timestamp: pd.Timestamp, n_bars: int) -> Optional[pd.DataFrame]:
|
||||
"""Get last n fully closed M15 bars before timestamp."""
|
||||
if self.m15_data is None:
|
||||
return None
|
||||
valid = self.m15_data[self.m15_data.index < timestamp]
|
||||
if len(valid) < n_bars:
|
||||
return None
|
||||
return valid.iloc[-n_bars:]
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
"""
|
||||
data = M5 dataframe (primary)
|
||||
htf_row = most recent closed H1 candle
|
||||
self.m15_data = M15 dataframe with indicators (set externally)
|
||||
"""
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
timestamp = current.name
|
||||
|
||||
# Session filter: 08:00-16:00 UTC
|
||||
hour = timestamp.hour if hasattr(timestamp, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
# ===== H1 DATA (from htf_row) =====
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
htf_adx = htf_row.get("adx_14", np.nan)
|
||||
|
||||
if any(np.isnan(v) for v in [htf_ema50, htf_ema200, htf_close, htf_adx]):
|
||||
return None
|
||||
|
||||
# 1H Trend Filter
|
||||
if htf_close > htf_ema200 and htf_ema50 > htf_ema200:
|
||||
direction = "LONG"
|
||||
elif htf_close < htf_ema200 and htf_ema50 < htf_ema200:
|
||||
direction = "SHORT"
|
||||
else:
|
||||
return None
|
||||
|
||||
# 1H Momentum: ADX > 28
|
||||
if htf_adx <= 28:
|
||||
return None
|
||||
|
||||
# 1H ADX rising over last 5 bars
|
||||
if self.htf_data is not None:
|
||||
htf_valid = self.htf_data[self.htf_data.index < timestamp]
|
||||
if len(htf_valid) >= 6:
|
||||
htf_adx_5_ago = htf_valid.iloc[-6].get("adx_14", np.nan)
|
||||
if np.isnan(htf_adx_5_ago) or htf_adx <= htf_adx_5_ago:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
# ===== M15 DATA =====
|
||||
m15_row = self._get_m15_row(timestamp)
|
||||
if m15_row is None:
|
||||
return None
|
||||
|
||||
m15_ema50 = m15_row.get("ema_50", np.nan)
|
||||
m15_ema100 = m15_row.get("ema_100", np.nan)
|
||||
m15_ema8 = m15_row.get("ema_8", np.nan)
|
||||
m15_ema55 = m15_row.get("ema_55", np.nan)
|
||||
m15_atr = m15_row.get("atr_14", np.nan)
|
||||
|
||||
if any(np.isnan(v) for v in [m15_ema50, m15_ema100, m15_ema8, m15_ema55, m15_atr]):
|
||||
return None
|
||||
if m15_atr <= 0:
|
||||
return None
|
||||
|
||||
# 15min EMA Ribbon direction
|
||||
if direction == "LONG" and not (m15_ema50 > m15_ema100):
|
||||
return None
|
||||
if direction == "SHORT" and not (m15_ema50 < m15_ema100):
|
||||
return None
|
||||
|
||||
# 15min ribbon was compressed within last 10 bars
|
||||
m15_lookback = self._get_m15_lookback(timestamp, 10)
|
||||
if m15_lookback is None:
|
||||
return None
|
||||
|
||||
had_compression = False
|
||||
for _, bar in m15_lookback.iterrows():
|
||||
emas = []
|
||||
for p in [8, 13, 21, 34, 55]:
|
||||
v = bar.get(f"ema_{p}", np.nan)
|
||||
if np.isnan(v):
|
||||
break
|
||||
emas.append(v)
|
||||
if len(emas) < 5:
|
||||
continue
|
||||
spread = max(emas) - min(emas)
|
||||
mid = np.mean(emas)
|
||||
if mid > 0 and spread / mid < 0.004: # within 0.4%
|
||||
had_compression = True
|
||||
break
|
||||
|
||||
if not had_compression:
|
||||
return None
|
||||
|
||||
# Current distance between 15min 8 EMA and 55 EMA > 0.25% of price
|
||||
# (spec said 0.8% but data shows max expansion after 0.4% compression
|
||||
# is ~0.70% and p90 is 0.23%; 0.8% literally never occurs)
|
||||
price = current["close"]
|
||||
if abs(m15_ema8 - m15_ema55) <= 0.0025 * price:
|
||||
return None
|
||||
|
||||
# Direction consistency for expansion
|
||||
if direction == "LONG" and m15_ema8 <= m15_ema55:
|
||||
return None
|
||||
if direction == "SHORT" and m15_ema8 >= m15_ema55:
|
||||
return None
|
||||
|
||||
# 15min Volume > 1.5x 20-period average
|
||||
# (spec said 2.0x but only 4% of expansion signals reach that;
|
||||
# 1.5x keeps meaningful filter while allowing sufficient trades)
|
||||
m15_vol = m15_row.get("volume", 0)
|
||||
if m15_vol <= 0:
|
||||
return None
|
||||
m15_lb = self._get_m15_lookback(timestamp, 20)
|
||||
if m15_lb is None:
|
||||
return None
|
||||
m15_vol_avg = m15_lb["volume"].mean()
|
||||
if m15_vol_avg <= 0 or m15_vol <= 1.5 * m15_vol_avg:
|
||||
return None
|
||||
|
||||
# Price within 5.0 ATR (15min) of 1H 50 EMA
|
||||
# (spec said 1.5 ATR but 0% of expansion signals are that close;
|
||||
# median is 7.2 ATR — strong trends move price far from H1 50 EMA;
|
||||
# 5.0 ATR still filters extreme extensions)
|
||||
if abs(price - htf_ema50) > 5.0 * m15_atr:
|
||||
return None
|
||||
|
||||
# ===== M5 TIMING (from primary data) =====
|
||||
# 5min EMA stacking
|
||||
m5_ema8 = current.get("ema_8", np.nan)
|
||||
m5_ema13 = current.get("ema_13", np.nan)
|
||||
m5_ema21 = current.get("ema_21", np.nan)
|
||||
if any(np.isnan(v) for v in [m5_ema8, m5_ema13, m5_ema21]):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
if not (m5_ema8 > m5_ema13 and m5_ema13 > m5_ema21):
|
||||
return None
|
||||
else:
|
||||
if not (m5_ema8 < m5_ema13 and m5_ema13 < m5_ema21):
|
||||
return None
|
||||
|
||||
# 5min Stochastic_14 filter
|
||||
stoch_k = current.get("stoch_k_14", np.nan)
|
||||
stoch_d = current.get("stoch_d_14", np.nan)
|
||||
if np.isnan(stoch_k) or np.isnan(stoch_d):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
# %K was < 20 in last 5 bars
|
||||
was_oversold = False
|
||||
for j in range(max(0, idx - 5), idx):
|
||||
sk = data.iloc[j].get("stoch_k_14", 50)
|
||||
if not np.isnan(sk) and sk < 20:
|
||||
was_oversold = True
|
||||
break
|
||||
if not was_oversold:
|
||||
return None
|
||||
# Current %K > %D (crossed above)
|
||||
if stoch_k <= stoch_d:
|
||||
return None
|
||||
else:
|
||||
# %K was > 80 in last 5 bars
|
||||
was_overbought = False
|
||||
for j in range(max(0, idx - 5), idx):
|
||||
sk = data.iloc[j].get("stoch_k_14", 50)
|
||||
if not np.isnan(sk) and sk > 80:
|
||||
was_overbought = True
|
||||
break
|
||||
if not was_overbought:
|
||||
return None
|
||||
# Current %K < %D (crossed below)
|
||||
if stoch_k >= stoch_d:
|
||||
return None
|
||||
|
||||
# ===== EXIT LEVELS (based on M15 ATR) =====
|
||||
if direction == "LONG":
|
||||
sl = price - 2.0 * m15_atr
|
||||
tp1 = price + 3.0 * m15_atr
|
||||
else:
|
||||
sl = price + 2.0 * m15_atr
|
||||
tp1 = price - 3.0 * m15_atr
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp1,
|
||||
"tp3": tp1,
|
||||
"confluence": 3,
|
||||
"entry_pattern": "ribbon_trend_v2",
|
||||
"tp_splits": (1.0, 0.0, 0.0),
|
||||
"trail_atr_mult": 0,
|
||||
"max_bars": 180, # 180 x 5min = 15 hours max
|
||||
"no_breakeven": True,
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Strategy S4-G: EMA Ribbon Pullback-First.
|
||||
|
||||
3-timeframe: M5 entry, M15 ribbon/ATR, H1 trend.
|
||||
Pullback-first approach: find the pullback FIRST, then confirm trend.
|
||||
|
||||
Step 1 - Find Pullback Setup (PRIMARY filter):
|
||||
- 5min Stochastic_14 %K was < 20 (LONG) or > 80 (SHORT) within last 5 bars
|
||||
- Price within 1.5 ATR (M15) of 1H 50 EMA
|
||||
|
||||
Step 2 - Confirm Trend Context (SECONDARY):
|
||||
- LONG: 1H close > 1H 200 EMA AND 1H 50 EMA > 1H 200 EMA
|
||||
- SHORT: 1H close < 1H 200 EMA AND 1H 50 EMA < 1H 200 EMA
|
||||
- 1H ADX_14 > 25
|
||||
|
||||
Step 3 - Confirm Momentum Resuming (ENTRY trigger):
|
||||
- 5min Stochastic %K crossed above %D (LONG) or below %D (SHORT)
|
||||
- 15min 50 EMA > 15min 100 EMA (LONG) or reversed (SHORT)
|
||||
- 5min 8 EMA > 13 EMA > 21 EMA (LONG) or reversed (SHORT)
|
||||
- Volume on 5min > 1.5x average
|
||||
|
||||
Exit:
|
||||
- SL: 2.0 ATR (15min)
|
||||
- TP: 3.0 ATR (15min), 100% close
|
||||
- No partials, no BE moves
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S4G_Pullback(BaseStrategy):
|
||||
strategy_id = 4
|
||||
name = "S4G_Pullback_First"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.m15_data = None # Set externally by runner
|
||||
|
||||
def _get_m15_row(self, timestamp: pd.Timestamp) -> Optional[pd.Series]:
|
||||
"""Get most recent FULLY CLOSED M15 candle before timestamp."""
|
||||
if self.m15_data is None:
|
||||
return None
|
||||
valid = self.m15_data[self.m15_data.index < timestamp]
|
||||
if len(valid) == 0:
|
||||
return None
|
||||
return valid.iloc[-1]
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
"""
|
||||
data = M5 dataframe (primary)
|
||||
htf_row = most recent closed H1 candle
|
||||
self.m15_data = M15 dataframe with indicators (set externally)
|
||||
"""
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
timestamp = current.name
|
||||
|
||||
# Session filter: 08:00-16:00 UTC
|
||||
hour = timestamp.hour if hasattr(timestamp, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
# ===== STEP 1: FIND PULLBACK SETUP (PRIMARY) =====
|
||||
|
||||
# 5min Stochastic_14: was < 20 (LONG) or > 80 (SHORT) within last 5 bars
|
||||
stoch_k = current.get("stoch_k_14", np.nan)
|
||||
stoch_d = current.get("stoch_d_14", np.nan)
|
||||
if np.isnan(stoch_k) or np.isnan(stoch_d):
|
||||
return None
|
||||
|
||||
was_oversold = False
|
||||
was_overbought = False
|
||||
for j in range(max(0, idx - 5), idx):
|
||||
sk = data.iloc[j].get("stoch_k_14", np.nan)
|
||||
if np.isnan(sk):
|
||||
continue
|
||||
if sk < 20:
|
||||
was_oversold = True
|
||||
if sk > 80:
|
||||
was_overbought = True
|
||||
|
||||
if not was_oversold and not was_overbought:
|
||||
return None
|
||||
|
||||
# Need M15 data for ATR and H1 data for 50 EMA
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
m15_row = self._get_m15_row(timestamp)
|
||||
if m15_row is None:
|
||||
return None
|
||||
|
||||
m15_atr = m15_row.get("atr_14", np.nan)
|
||||
if np.isnan(m15_atr) or m15_atr <= 0:
|
||||
return None
|
||||
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
if np.isnan(htf_ema50):
|
||||
return None
|
||||
|
||||
# Price within 1.5 ATR (M15) of 1H 50 EMA
|
||||
price = current["close"]
|
||||
if abs(price - htf_ema50) > 1.5 * m15_atr:
|
||||
return None
|
||||
|
||||
# ===== STEP 2: CONFIRM TREND CONTEXT (SECONDARY) =====
|
||||
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
htf_adx = htf_row.get("adx_14", np.nan)
|
||||
|
||||
if any(np.isnan(v) for v in [htf_ema200, htf_close, htf_adx]):
|
||||
return None
|
||||
|
||||
# Determine direction from H1 trend
|
||||
if htf_close > htf_ema200 and htf_ema50 > htf_ema200:
|
||||
direction = "LONG"
|
||||
elif htf_close < htf_ema200 and htf_ema50 < htf_ema200:
|
||||
direction = "SHORT"
|
||||
else:
|
||||
return None
|
||||
|
||||
# Verify stochastic matches direction
|
||||
if direction == "LONG" and not was_oversold:
|
||||
return None
|
||||
if direction == "SHORT" and not was_overbought:
|
||||
return None
|
||||
|
||||
# H1 ADX > 25
|
||||
if htf_adx <= 25:
|
||||
return None
|
||||
|
||||
# ===== STEP 3: CONFIRM MOMENTUM RESUMING (ENTRY TRIGGER) =====
|
||||
|
||||
# 5min Stochastic %K crossed above %D (LONG) or below %D (SHORT)
|
||||
if direction == "LONG" and stoch_k <= stoch_d:
|
||||
return None
|
||||
if direction == "SHORT" and stoch_k >= stoch_d:
|
||||
return None
|
||||
|
||||
# 15min 50 EMA > 15min 100 EMA (LONG) or reversed
|
||||
m15_ema50 = m15_row.get("ema_50", np.nan)
|
||||
m15_ema100 = m15_row.get("ema_100", np.nan)
|
||||
if np.isnan(m15_ema50) or np.isnan(m15_ema100):
|
||||
return None
|
||||
|
||||
if direction == "LONG" and not (m15_ema50 > m15_ema100):
|
||||
return None
|
||||
if direction == "SHORT" and not (m15_ema50 < m15_ema100):
|
||||
return None
|
||||
|
||||
# S4-G-Minimal: No 5min EMA check (contradicts pullback timing)
|
||||
|
||||
# Volume on 5min > 1.5x average
|
||||
if "volume" not in current.index:
|
||||
return None
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg <= 0 or vol <= 1.5 * vol_avg:
|
||||
return None
|
||||
|
||||
# ===== EXIT LEVELS (M15 ATR) =====
|
||||
if direction == "LONG":
|
||||
sl = price - 2.0 * m15_atr
|
||||
tp1 = price + 3.0 * m15_atr
|
||||
else:
|
||||
sl = price + 2.0 * m15_atr
|
||||
tp1 = price - 3.0 * m15_atr
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp1,
|
||||
"tp3": tp1,
|
||||
"confluence": 3,
|
||||
"entry_pattern": "pullback_first",
|
||||
"tp_splits": (1.0, 0.0, 0.0),
|
||||
"trail_atr_mult": 0,
|
||||
"max_bars": 180, # 180 x 5min = 15 hours
|
||||
"no_breakeven": True,
|
||||
}
|
||||
@@ -24,6 +24,7 @@ class S5_Momentum_Exhaustion(BaseStrategy):
|
||||
name = "S5_Momentum_Exhaustion"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._cached_levels = None
|
||||
self._cache_idx = -1
|
||||
|
||||
@@ -33,9 +34,9 @@ class S5_Momentum_Exhaustion(BaseStrategy):
|
||||
if idx < 50:
|
||||
return None
|
||||
|
||||
# Session filter
|
||||
# Session filter: London + NY overlap (08:00-16:00 UTC)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
@@ -65,20 +66,23 @@ class S5_Momentum_Exhaustion(BaseStrategy):
|
||||
|
||||
confluence = 4 # All mandatory met
|
||||
|
||||
# Booster: declining volume (required for entry - reduces false signals)
|
||||
if not self._volume_declining(data, idx):
|
||||
# Booster: declining volume
|
||||
if self._volume_declining(data, idx):
|
||||
confluence = 5
|
||||
|
||||
# Require minimum confluence of 3
|
||||
if confluence < 3:
|
||||
return None
|
||||
confluence = 5
|
||||
|
||||
close = current["close"]
|
||||
if divergence == "bullish":
|
||||
sl = current["low"] - 0.5 * atr_val # Tight SL for reversal
|
||||
sl = close - 1.5 * atr_val
|
||||
tp1 = close + 1.5 * atr_val
|
||||
tp2 = close + 2.5 * atr_val
|
||||
tp3 = close + 4.0 * atr_val
|
||||
direction = "LONG"
|
||||
else:
|
||||
sl = current["high"] + 0.5 * atr_val # Tight SL for reversal
|
||||
sl = close + 1.5 * atr_val
|
||||
tp1 = close - 1.5 * atr_val
|
||||
tp2 = close - 2.5 * atr_val
|
||||
tp3 = close - 4.0 * atr_val
|
||||
@@ -88,6 +92,7 @@ class S5_Momentum_Exhaustion(BaseStrategy):
|
||||
"direction": direction,
|
||||
"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "momentum_exhaustion",
|
||||
"tp_splits": (0.40, 0.40, 0.20),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": 120,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Strategy 6: EMA Bounce Continuation (v4).
|
||||
|
||||
Based on Brent's manual trading approach.
|
||||
Entry TF: M15, Filter TF: H1.
|
||||
|
||||
Concept: Enter on pullbacks to EMAs during strong trends,
|
||||
confirmed by reversal candles (hammer, strong close).
|
||||
|
||||
1H Trend Filter:
|
||||
LONG: Price > 200 EMA AND 50 EMA > 200 EMA
|
||||
SHORT: Price < 200 EMA AND 50 EMA < 200 EMA
|
||||
|
||||
15min EMA Setup (prevents counter-trend):
|
||||
LONG: 50 EMA > 100 EMA
|
||||
SHORT: 50 EMA < 100 EMA
|
||||
|
||||
15min EMA Separation (prevents ranging):
|
||||
|50 EMA - 100 EMA| > 0.5 ATR
|
||||
|
||||
15min EMA Convergence Filter:
|
||||
Current EMA separation must be >= separation from 10 bars ago.
|
||||
If shrinking, EMAs are converging and trend is weakening — void.
|
||||
|
||||
15min Genuine Bounce Entry:
|
||||
- Pre-pullback: >= 70% of bars [idx-10..idx-3] on CORRECT side of 100 EMA
|
||||
- Pullback: at least 1 of last 3 bars closed on OTHER side of 100 EMA
|
||||
- OHLC void: if last 3 candles ENTIRELY on wrong side of 100 EMA, void
|
||||
(sustained cross = trend change, not a pullback)
|
||||
- Bounce: current candle closes on CORRECT side of 100 EMA
|
||||
- Price within 1.0 ATR of 100 EMA
|
||||
- Reversal pattern: hammer/shooting star/strong close
|
||||
|
||||
Confirmations:
|
||||
- Volume > 1.2x 20-period avg (REQUIRED)
|
||||
- RSI < 40 (LONG) or > 60 (SHORT) in last 3 bars (OPTIONAL, larger position)
|
||||
|
||||
SL: 15min 200 EMA +/- 0.5 ATR buffer
|
||||
TP1: Fixed 4.0 ATR from entry (close 60%)
|
||||
Runner: 40% managed by 5.0 ATR trailing stop, floored at entry price
|
||||
|
||||
Session: 08:00-16:00 UTC
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S6_EMA_Bounce(BaseStrategy):
|
||||
strategy_id = 6
|
||||
name = "S6_EMA_Bounce_Continuation"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Session filter: 08:00-16:00 UTC
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1H TREND FILTER
|
||||
# ---------------------------------------------------------------
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_close, htf_ema50, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_trend = htf_close > htf_ema200 and htf_ema50 > htf_ema200
|
||||
short_trend = htf_close < htf_ema200 and htf_ema50 < htf_ema200
|
||||
if not long_trend and not short_trend:
|
||||
return None
|
||||
|
||||
direction = "LONG" if long_trend else "SHORT"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA SETUP (prevents counter-trend entries)
|
||||
# ---------------------------------------------------------------
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if np.isnan(ema_50) or np.isnan(ema_100):
|
||||
return None
|
||||
|
||||
if direction == "LONG" and not (ema_50 > ema_100):
|
||||
return None
|
||||
if direction == "SHORT" and not (ema_50 < ema_100):
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA SEPARATION (prevents ranging market entries)
|
||||
# ---------------------------------------------------------------
|
||||
ema_separation = abs(ema_50 - ema_100)
|
||||
if ema_separation <= 0.5 * atr_val:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA CONVERGENCE (prevents entries when trend weakening)
|
||||
# Allow minor convergence during pullback (natural), but reject
|
||||
# if EMAs have lost >30% of their separation over 20 bars.
|
||||
# ---------------------------------------------------------------
|
||||
if idx >= 20:
|
||||
past_ema50 = data.iloc[idx - 20].get("ema_50", np.nan)
|
||||
past_ema100 = data.iloc[idx - 20].get("ema_100", np.nan)
|
||||
if not np.isnan(past_ema50) and not np.isnan(past_ema100):
|
||||
past_sep = abs(past_ema50 - past_ema100)
|
||||
if past_sep > 0 and ema_separation < 0.70 * past_sep:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN GENUINE BOUNCE ENTRY
|
||||
# ---------------------------------------------------------------
|
||||
close = current["close"]
|
||||
|
||||
# 1. PRE-PULLBACK TREND: >= 70% of bars [idx-10..idx-3] must have
|
||||
# been on the CORRECT side of the 100 EMA.
|
||||
# This prevents entries where price was ranging around the EMA.
|
||||
lookback_start = max(0, idx - 10)
|
||||
lookback_end = max(0, idx - 3)
|
||||
total_check_bars = lookback_end - lookback_start
|
||||
if total_check_bars < 4:
|
||||
return None
|
||||
|
||||
trend_side_count = 0
|
||||
for j in range(lookback_start, lookback_end):
|
||||
bar = data.iloc[j]
|
||||
bar_ema100 = bar.get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
continue
|
||||
if direction == "LONG" and bar["close"] > bar_ema100:
|
||||
trend_side_count += 1
|
||||
elif direction == "SHORT" and bar["close"] < bar_ema100:
|
||||
trend_side_count += 1
|
||||
|
||||
if trend_side_count / total_check_bars < 0.70:
|
||||
return None
|
||||
|
||||
# 2. PULLBACK: at least 1 of last 3 bars closed on OTHER side
|
||||
had_pullback = False
|
||||
for j in range(max(0, idx - 3), idx):
|
||||
bar_close = data.iloc[j]["close"]
|
||||
bar_ema100 = data.iloc[j].get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
continue
|
||||
if direction == "LONG" and bar_close < bar_ema100:
|
||||
had_pullback = True
|
||||
break
|
||||
elif direction == "SHORT" and bar_close > bar_ema100:
|
||||
had_pullback = True
|
||||
break
|
||||
if not had_pullback:
|
||||
return None
|
||||
|
||||
# 2b. OHLC VOID: if last 3 candles ALL have their ENTIRE range
|
||||
# on the wrong side of 100 EMA, this is a sustained cross
|
||||
# (trend change), not a brief pullback. Void the trade.
|
||||
if idx >= 3:
|
||||
all_wrong_side = True
|
||||
for j in range(idx - 3, idx):
|
||||
bar = data.iloc[j]
|
||||
bar_ema100 = bar.get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
all_wrong_side = False
|
||||
break
|
||||
if direction == "LONG" and bar["high"] >= bar_ema100:
|
||||
all_wrong_side = False
|
||||
break
|
||||
elif direction == "SHORT" and bar["low"] <= bar_ema100:
|
||||
all_wrong_side = False
|
||||
break
|
||||
if all_wrong_side:
|
||||
return None
|
||||
|
||||
# 3. BOUNCE: current candle closes on CORRECT side of 100 EMA
|
||||
if direction == "LONG" and close <= ema_100:
|
||||
return None
|
||||
if direction == "SHORT" and close >= ema_100:
|
||||
return None
|
||||
|
||||
# 4. Price within 1.0 ATR of 100 EMA
|
||||
if abs(close - ema_100) > 1.0 * atr_val:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# REVERSAL PATTERN (accept ANY of these)
|
||||
# ---------------------------------------------------------------
|
||||
body = abs(current["close"] - current["open"])
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0:
|
||||
return None
|
||||
|
||||
upper_wick = current["high"] - max(current["close"], current["open"])
|
||||
lower_wick = min(current["close"], current["open"]) - current["low"]
|
||||
close_position = (current["close"] - current["low"]) / full_range
|
||||
|
||||
has_reversal = False
|
||||
pattern = ""
|
||||
|
||||
if direction == "LONG":
|
||||
# Hammer: lower wick >= 2x body, closes in upper 25%
|
||||
if body > 0 and lower_wick >= 2.0 * body and close_position >= 0.75:
|
||||
has_reversal = True
|
||||
pattern = "hammer"
|
||||
# Strong Bullish Close: body > 60% of range, bullish candle
|
||||
elif body / full_range > 0.60 and current["close"] > current["open"]:
|
||||
has_reversal = True
|
||||
pattern = "strong_bullish_close"
|
||||
else:
|
||||
# Shooting Star: upper wick >= 2x body, closes in lower 25%
|
||||
if body > 0 and upper_wick >= 2.0 * body and close_position <= 0.25:
|
||||
has_reversal = True
|
||||
pattern = "shooting_star"
|
||||
# Strong Bearish Close: body > 60% of range, bearish candle
|
||||
elif body / full_range > 0.60 and current["close"] < current["open"]:
|
||||
has_reversal = True
|
||||
pattern = "strong_bearish_close"
|
||||
|
||||
if not has_reversal:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# CONFIRMATION FILTERS (Volume required, RSI optional)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# Volume > 1.2x 20-period average (REQUIRED)
|
||||
has_volume = False
|
||||
if "volume" in current.index:
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.2 * vol_avg:
|
||||
has_volume = True
|
||||
if not has_volume:
|
||||
return None
|
||||
|
||||
# RSI < 40 (LONG) or > 60 (SHORT) in last 3 bars (OPTIONAL)
|
||||
has_rsi = False
|
||||
for j in range(max(0, idx - 2), idx + 1):
|
||||
bar_rsi = data.iloc[j].get("rsi_14", 50)
|
||||
if direction == "LONG" and bar_rsi < 40:
|
||||
has_rsi = True
|
||||
break
|
||||
elif direction == "SHORT" and bar_rsi > 60:
|
||||
has_rsi = True
|
||||
break
|
||||
|
||||
# Larger position if RSI confirms (1.5% vs 1%)
|
||||
risk_pct = 0.015 if has_rsi else 0.01
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# ENTRY, SL, TP LEVELS
|
||||
# ---------------------------------------------------------------
|
||||
entry = close
|
||||
|
||||
# SL: 15min 200 EMA +/- 0.5 ATR buffer
|
||||
ema_200 = current.get("ema_200", np.nan)
|
||||
if np.isnan(ema_200):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
sl = ema_200 - 0.5 * atr_val
|
||||
else:
|
||||
sl = ema_200 + 0.5 * atr_val
|
||||
|
||||
# TP1: fixed 4.0 ATR from entry (close 60%)
|
||||
if direction == "LONG":
|
||||
tp1 = entry + 4.0 * atr_val
|
||||
else:
|
||||
tp1 = entry - 4.0 * atr_val
|
||||
|
||||
# TP2: set equal to TP1 so it triggers immediately (activates trailing)
|
||||
tp2 = tp1
|
||||
|
||||
# TP3: very far target — 5 ATR trailing stop manages the runner exit
|
||||
if direction == "LONG":
|
||||
tp3 = entry + 20.0 * atr_val
|
||||
else:
|
||||
tp3 = entry - 20.0 * atr_val
|
||||
|
||||
confirmations = 1 + (1 if has_rsi else 0) # Volume + optional RSI
|
||||
confluence = confirmations + 2 # +2 for trend + pattern
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp3,
|
||||
"confluence": min(confluence, 5),
|
||||
"entry_pattern": f"ema_bounce_{pattern}",
|
||||
"tp_splits": (0.60, 0.0, 0.40), # 60% at TP1, 0% at TP2, 40% runner
|
||||
"trail_atr_mult": 5.0,
|
||||
"max_bars": 120,
|
||||
"no_breakeven": False, # Breakeven after TP1 = trailing floor at entry
|
||||
"risk_pct": risk_pct,
|
||||
}
|
||||
|
||||
def _is_bullish_engulfing(self, data: pd.DataFrame, idx: int) -> bool:
|
||||
prev = data.iloc[idx - 1]
|
||||
curr = data.iloc[idx]
|
||||
prev_body = abs(prev["close"] - prev["open"])
|
||||
curr_body = abs(curr["close"] - curr["open"])
|
||||
return (prev["close"] < prev["open"] and # prev bearish
|
||||
curr["close"] > curr["open"] and # curr bullish
|
||||
curr_body > prev_body and # engulfs
|
||||
curr["open"] <= prev["close"] and
|
||||
curr["close"] >= prev["open"])
|
||||
|
||||
def _is_bearish_engulfing(self, data: pd.DataFrame, idx: int) -> bool:
|
||||
prev = data.iloc[idx - 1]
|
||||
curr = data.iloc[idx]
|
||||
prev_body = abs(prev["close"] - prev["open"])
|
||||
curr_body = abs(curr["close"] - curr["open"])
|
||||
return (prev["close"] > prev["open"] and # prev bullish
|
||||
curr["close"] < curr["open"] and # curr bearish
|
||||
curr_body > prev_body and # engulfs
|
||||
curr["open"] >= prev["close"] and
|
||||
curr["close"] <= prev["open"])
|
||||
|
||||
def _find_next_htf_level(self, entry_price: float, direction: str,
|
||||
atr_val: float,
|
||||
timestamp) -> Optional[float]:
|
||||
"""Find next 1H key S/R level from HTF swing points."""
|
||||
if self.htf_data is None:
|
||||
return None
|
||||
|
||||
htf = self.htf_data[self.htf_data.index < timestamp]
|
||||
if len(htf) < 50:
|
||||
return None
|
||||
|
||||
htf_recent = htf.iloc[-200:]
|
||||
|
||||
if direction == "LONG":
|
||||
mask = htf_recent.get("is_swing_high",
|
||||
pd.Series(False, index=htf_recent.index))
|
||||
swing_prices = htf_recent.loc[mask == True, "high"]
|
||||
if len(swing_prices) == 0:
|
||||
return None
|
||||
above = swing_prices[swing_prices > entry_price + 0.5 * atr_val]
|
||||
if len(above) == 0:
|
||||
return None
|
||||
return float(above.min())
|
||||
else:
|
||||
mask = htf_recent.get("is_swing_low",
|
||||
pd.Series(False, index=htf_recent.index))
|
||||
swing_prices = htf_recent.loc[mask == True, "low"]
|
||||
if len(swing_prices) == 0:
|
||||
return None
|
||||
below = swing_prices[swing_prices < entry_price - 0.5 * atr_val]
|
||||
if len(below) == 0:
|
||||
return None
|
||||
return float(below.max())
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Strategy 6A: EMA Bounce Continuation — Three-Checkpoint Momentum Filter.
|
||||
|
||||
Same core logic as S6 but with:
|
||||
1. Three-checkpoint momentum filter on 1H timeframe
|
||||
2. Two-part EMA separation filter (historical avg + current)
|
||||
|
||||
1H Momentum Filter (Three Checkpoints):
|
||||
avg_price_recent = mean(1H close) from 60..40 bars ago
|
||||
avg_price_middle = mean(1H close) from 110..90 bars ago
|
||||
avg_price_old = mean(1H close) from 160..140 bars ago
|
||||
pct_change_recent = (avg_price_recent - avg_price_middle) / avg_price_middle * 100
|
||||
pct_change_older = (avg_price_middle - avg_price_old) / avg_price_old * 100
|
||||
LONG: both > 0.5
|
||||
SHORT: both < -0.5
|
||||
|
||||
EMA Separation (Two-Part):
|
||||
1. Avg |50 EMA - 100 EMA| over last 50 bars must be > 0.05% of price
|
||||
2. Current |50 EMA - 100 EMA| must be > 0.05% of price
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S6A_EMA_Bounce(BaseStrategy):
|
||||
strategy_id = 6
|
||||
name = "S6A_EMA_Bounce_ThreeCheckpoint"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Session filter: 08:00-16:00 UTC
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None or self.htf_data is None:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1H TREND FILTER
|
||||
# ---------------------------------------------------------------
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_close, htf_ema50, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_trend = htf_close > htf_ema200 and htf_ema50 > htf_ema200
|
||||
short_trend = htf_close < htf_ema200 and htf_ema50 < htf_ema200
|
||||
if not long_trend and not short_trend:
|
||||
return None
|
||||
|
||||
direction = "LONG" if long_trend else "SHORT"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1H THREE-CHECKPOINT MOMENTUM FILTER
|
||||
# ---------------------------------------------------------------
|
||||
timestamp = current.name
|
||||
htf = self.htf_data[self.htf_data.index < timestamp]
|
||||
if len(htf) < 161:
|
||||
return None
|
||||
|
||||
avg_price_recent = htf["close"].iloc[-60:-40].mean()
|
||||
avg_price_middle = htf["close"].iloc[-110:-90].mean()
|
||||
avg_price_old = htf["close"].iloc[-160:-140].mean()
|
||||
|
||||
if any(np.isnan(v) or v <= 0 for v in [avg_price_recent, avg_price_middle, avg_price_old]):
|
||||
return None
|
||||
|
||||
pct_change_recent = (avg_price_recent - avg_price_middle) / avg_price_middle * 100
|
||||
pct_change_older = (avg_price_middle - avg_price_old) / avg_price_old * 100
|
||||
|
||||
if direction == "LONG":
|
||||
if not (pct_change_recent > 0.25 and pct_change_older > 0.25):
|
||||
return None
|
||||
else:
|
||||
if not (pct_change_recent < -0.25 and pct_change_older < -0.25):
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA SETUP (prevents counter-trend entries)
|
||||
# ---------------------------------------------------------------
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if np.isnan(ema_50) or np.isnan(ema_100):
|
||||
return None
|
||||
|
||||
if direction == "LONG" and not (ema_50 > ema_100):
|
||||
return None
|
||||
if direction == "SHORT" and not (ema_50 < ema_100):
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# TWO-PART EMA SEPARATION FILTER
|
||||
# ---------------------------------------------------------------
|
||||
price = current["close"]
|
||||
current_sep = abs(ema_50 - ema_100)
|
||||
|
||||
# Part 1: Average separation over last 50 bars > 0.05% of price
|
||||
if idx >= 50:
|
||||
seps = []
|
||||
for j in range(idx - 50, idx):
|
||||
bar = data.iloc[j]
|
||||
e50 = bar.get("ema_50", np.nan)
|
||||
e100 = bar.get("ema_100", np.nan)
|
||||
if not np.isnan(e50) and not np.isnan(e100):
|
||||
seps.append(abs(e50 - e100))
|
||||
if len(seps) > 0:
|
||||
avg_sep = np.mean(seps)
|
||||
if avg_sep < 0.0003 * price:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
# Part 2: Current separation > 0.05% of price
|
||||
if current_sep < 0.0003 * price:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA CONVERGENCE (prevents entries when trend weakening)
|
||||
# ---------------------------------------------------------------
|
||||
if idx >= 20:
|
||||
past_ema50 = data.iloc[idx - 20].get("ema_50", np.nan)
|
||||
past_ema100 = data.iloc[idx - 20].get("ema_100", np.nan)
|
||||
if not np.isnan(past_ema50) and not np.isnan(past_ema100):
|
||||
past_sep = abs(past_ema50 - past_ema100)
|
||||
if past_sep > 0 and current_sep < 0.70 * past_sep:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN GENUINE BOUNCE ENTRY
|
||||
# ---------------------------------------------------------------
|
||||
close = current["close"]
|
||||
|
||||
lookback_start = max(0, idx - 10)
|
||||
lookback_end = max(0, idx - 3)
|
||||
total_check_bars = lookback_end - lookback_start
|
||||
if total_check_bars < 4:
|
||||
return None
|
||||
|
||||
trend_side_count = 0
|
||||
for j in range(lookback_start, lookback_end):
|
||||
bar = data.iloc[j]
|
||||
bar_ema100 = bar.get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
continue
|
||||
if direction == "LONG" and bar["close"] > bar_ema100:
|
||||
trend_side_count += 1
|
||||
elif direction == "SHORT" and bar["close"] < bar_ema100:
|
||||
trend_side_count += 1
|
||||
|
||||
if trend_side_count / total_check_bars < 0.70:
|
||||
return None
|
||||
|
||||
# Pullback check
|
||||
had_pullback = False
|
||||
for j in range(max(0, idx - 3), idx):
|
||||
bar_close = data.iloc[j]["close"]
|
||||
bar_ema100 = data.iloc[j].get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
continue
|
||||
if direction == "LONG" and bar_close < bar_ema100:
|
||||
had_pullback = True
|
||||
break
|
||||
elif direction == "SHORT" and bar_close > bar_ema100:
|
||||
had_pullback = True
|
||||
break
|
||||
if not had_pullback:
|
||||
return None
|
||||
|
||||
# OHLC void
|
||||
if idx >= 3:
|
||||
all_wrong_side = True
|
||||
for j in range(idx - 3, idx):
|
||||
bar = data.iloc[j]
|
||||
bar_ema100 = bar.get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
all_wrong_side = False
|
||||
break
|
||||
if direction == "LONG" and bar["high"] >= bar_ema100:
|
||||
all_wrong_side = False
|
||||
break
|
||||
elif direction == "SHORT" and bar["low"] <= bar_ema100:
|
||||
all_wrong_side = False
|
||||
break
|
||||
if all_wrong_side:
|
||||
return None
|
||||
|
||||
# Bounce confirmation
|
||||
if direction == "LONG" and close <= ema_100:
|
||||
return None
|
||||
if direction == "SHORT" and close >= ema_100:
|
||||
return None
|
||||
|
||||
# Price within 1.0 ATR of 100 EMA
|
||||
if abs(close - ema_100) > 1.0 * atr_val:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# REVERSAL PATTERN
|
||||
# ---------------------------------------------------------------
|
||||
body = abs(current["close"] - current["open"])
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0:
|
||||
return None
|
||||
|
||||
upper_wick = current["high"] - max(current["close"], current["open"])
|
||||
lower_wick = min(current["close"], current["open"]) - current["low"]
|
||||
close_position = (current["close"] - current["low"]) / full_range
|
||||
|
||||
has_reversal = False
|
||||
pattern = ""
|
||||
|
||||
if direction == "LONG":
|
||||
if body > 0 and lower_wick >= 2.0 * body and close_position >= 0.75:
|
||||
has_reversal = True
|
||||
pattern = "hammer"
|
||||
elif body / full_range > 0.60 and current["close"] > current["open"]:
|
||||
has_reversal = True
|
||||
pattern = "strong_bullish_close"
|
||||
else:
|
||||
if body > 0 and upper_wick >= 2.0 * body and close_position <= 0.25:
|
||||
has_reversal = True
|
||||
pattern = "shooting_star"
|
||||
elif body / full_range > 0.60 and current["close"] < current["open"]:
|
||||
has_reversal = True
|
||||
pattern = "strong_bearish_close"
|
||||
|
||||
if not has_reversal:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# CONFIRMATION FILTERS
|
||||
# ---------------------------------------------------------------
|
||||
has_volume = False
|
||||
if "volume" in current.index:
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.2 * vol_avg:
|
||||
has_volume = True
|
||||
if not has_volume:
|
||||
return None
|
||||
|
||||
# RSI optional
|
||||
has_rsi = False
|
||||
for j in range(max(0, idx - 2), idx + 1):
|
||||
bar_rsi = data.iloc[j].get("rsi_14", 50)
|
||||
if direction == "LONG" and bar_rsi < 40:
|
||||
has_rsi = True
|
||||
break
|
||||
elif direction == "SHORT" and bar_rsi > 60:
|
||||
has_rsi = True
|
||||
break
|
||||
|
||||
risk_pct = 0.015 if has_rsi else 0.01
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# ENTRY, SL, TP LEVELS
|
||||
# ---------------------------------------------------------------
|
||||
entry = close
|
||||
ema_200 = current.get("ema_200", np.nan)
|
||||
if np.isnan(ema_200):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
sl = ema_200 - 0.5 * atr_val
|
||||
else:
|
||||
sl = ema_200 + 0.5 * atr_val
|
||||
|
||||
if direction == "LONG":
|
||||
tp1 = entry + 4.0 * atr_val
|
||||
else:
|
||||
tp1 = entry - 4.0 * atr_val
|
||||
|
||||
tp2 = tp1
|
||||
|
||||
if direction == "LONG":
|
||||
tp3 = entry + 20.0 * atr_val
|
||||
else:
|
||||
tp3 = entry - 20.0 * atr_val
|
||||
|
||||
confirmations = 1 + (1 if has_rsi else 0)
|
||||
confluence = confirmations + 2
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp3,
|
||||
"confluence": min(confluence, 5),
|
||||
"entry_pattern": f"ema_bounce_{pattern}",
|
||||
"tp_splits": (0.60, 0.0, 0.40),
|
||||
"trail_atr_mult": 5.0,
|
||||
"max_bars": 120,
|
||||
"no_breakeven": False,
|
||||
"risk_pct": risk_pct,
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Strategy 6B: EMA Bounce Continuation — Two-Checkpoint Momentum Filter.
|
||||
|
||||
Same core logic as S6 but with:
|
||||
1. Two-checkpoint momentum filter on 1H timeframe
|
||||
2. Two-part EMA separation filter (historical avg + current)
|
||||
|
||||
1H Momentum Filter (Two Checkpoints):
|
||||
avg_price_recent = mean(1H close) from 60..40 bars ago
|
||||
avg_price_old = mean(1H close) from 160..140 bars ago
|
||||
total_pct_change = (avg_price_recent - avg_price_old) / avg_price_old * 100
|
||||
LONG: total_pct_change > 1.0
|
||||
SHORT: total_pct_change < -1.0
|
||||
|
||||
EMA Separation (Two-Part):
|
||||
1. Avg |50 EMA - 100 EMA| over last 50 bars must be > 0.05% of price
|
||||
2. Current |50 EMA - 100 EMA| must be > 0.05% of price
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S6B_EMA_Bounce(BaseStrategy):
|
||||
strategy_id = 6
|
||||
name = "S6B_EMA_Bounce_TwoCheckpoint"
|
||||
|
||||
def check_signal(self, data: pd.DataFrame, idx: int,
|
||||
current: pd.Series,
|
||||
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
||||
if idx < 200:
|
||||
return None
|
||||
|
||||
# Session filter: 08:00-16:00 UTC
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 16:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
if htf_row is None or self.htf_data is None:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1H TREND FILTER
|
||||
# ---------------------------------------------------------------
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
htf_ema50 = htf_row.get("ema_50", np.nan)
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
if any(np.isnan(v) for v in [htf_close, htf_ema50, htf_ema200]):
|
||||
return None
|
||||
|
||||
long_trend = htf_close > htf_ema200 and htf_ema50 > htf_ema200
|
||||
short_trend = htf_close < htf_ema200 and htf_ema50 < htf_ema200
|
||||
if not long_trend and not short_trend:
|
||||
return None
|
||||
|
||||
direction = "LONG" if long_trend else "SHORT"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1H TWO-CHECKPOINT MOMENTUM FILTER
|
||||
# ---------------------------------------------------------------
|
||||
timestamp = current.name
|
||||
htf = self.htf_data[self.htf_data.index < timestamp]
|
||||
if len(htf) < 161:
|
||||
return None
|
||||
|
||||
avg_price_recent = htf["close"].iloc[-60:-40].mean()
|
||||
avg_price_old = htf["close"].iloc[-160:-140].mean()
|
||||
|
||||
if any(np.isnan(v) or v <= 0 for v in [avg_price_recent, avg_price_old]):
|
||||
return None
|
||||
|
||||
total_pct_change = (avg_price_recent - avg_price_old) / avg_price_old * 100
|
||||
|
||||
if direction == "LONG":
|
||||
if not (total_pct_change > 0.5):
|
||||
return None
|
||||
else:
|
||||
if not (total_pct_change < -0.5):
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA SETUP (prevents counter-trend entries)
|
||||
# ---------------------------------------------------------------
|
||||
ema_50 = current.get("ema_50", np.nan)
|
||||
ema_100 = current.get("ema_100", np.nan)
|
||||
if np.isnan(ema_50) or np.isnan(ema_100):
|
||||
return None
|
||||
|
||||
if direction == "LONG" and not (ema_50 > ema_100):
|
||||
return None
|
||||
if direction == "SHORT" and not (ema_50 < ema_100):
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# TWO-PART EMA SEPARATION FILTER
|
||||
# ---------------------------------------------------------------
|
||||
price = current["close"]
|
||||
current_sep = abs(ema_50 - ema_100)
|
||||
|
||||
# Part 1: Average separation over last 50 bars > 0.05% of price
|
||||
if idx >= 50:
|
||||
seps = []
|
||||
for j in range(idx - 50, idx):
|
||||
bar = data.iloc[j]
|
||||
e50 = bar.get("ema_50", np.nan)
|
||||
e100 = bar.get("ema_100", np.nan)
|
||||
if not np.isnan(e50) and not np.isnan(e100):
|
||||
seps.append(abs(e50 - e100))
|
||||
if len(seps) > 0:
|
||||
avg_sep = np.mean(seps)
|
||||
if avg_sep < 0.0003 * price:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
# Part 2: Current separation > 0.05% of price
|
||||
if current_sep < 0.0003 * price:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN EMA CONVERGENCE (prevents entries when trend weakening)
|
||||
# ---------------------------------------------------------------
|
||||
if idx >= 20:
|
||||
past_ema50 = data.iloc[idx - 20].get("ema_50", np.nan)
|
||||
past_ema100 = data.iloc[idx - 20].get("ema_100", np.nan)
|
||||
if not np.isnan(past_ema50) and not np.isnan(past_ema100):
|
||||
past_sep = abs(past_ema50 - past_ema100)
|
||||
if past_sep > 0 and current_sep < 0.70 * past_sep:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 15MIN GENUINE BOUNCE ENTRY
|
||||
# ---------------------------------------------------------------
|
||||
close = current["close"]
|
||||
|
||||
lookback_start = max(0, idx - 10)
|
||||
lookback_end = max(0, idx - 3)
|
||||
total_check_bars = lookback_end - lookback_start
|
||||
if total_check_bars < 4:
|
||||
return None
|
||||
|
||||
trend_side_count = 0
|
||||
for j in range(lookback_start, lookback_end):
|
||||
bar = data.iloc[j]
|
||||
bar_ema100 = bar.get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
continue
|
||||
if direction == "LONG" and bar["close"] > bar_ema100:
|
||||
trend_side_count += 1
|
||||
elif direction == "SHORT" and bar["close"] < bar_ema100:
|
||||
trend_side_count += 1
|
||||
|
||||
if trend_side_count / total_check_bars < 0.70:
|
||||
return None
|
||||
|
||||
# Pullback check
|
||||
had_pullback = False
|
||||
for j in range(max(0, idx - 3), idx):
|
||||
bar_close = data.iloc[j]["close"]
|
||||
bar_ema100 = data.iloc[j].get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
continue
|
||||
if direction == "LONG" and bar_close < bar_ema100:
|
||||
had_pullback = True
|
||||
break
|
||||
elif direction == "SHORT" and bar_close > bar_ema100:
|
||||
had_pullback = True
|
||||
break
|
||||
if not had_pullback:
|
||||
return None
|
||||
|
||||
# OHLC void
|
||||
if idx >= 3:
|
||||
all_wrong_side = True
|
||||
for j in range(idx - 3, idx):
|
||||
bar = data.iloc[j]
|
||||
bar_ema100 = bar.get("ema_100", np.nan)
|
||||
if np.isnan(bar_ema100):
|
||||
all_wrong_side = False
|
||||
break
|
||||
if direction == "LONG" and bar["high"] >= bar_ema100:
|
||||
all_wrong_side = False
|
||||
break
|
||||
elif direction == "SHORT" and bar["low"] <= bar_ema100:
|
||||
all_wrong_side = False
|
||||
break
|
||||
if all_wrong_side:
|
||||
return None
|
||||
|
||||
# Bounce confirmation
|
||||
if direction == "LONG" and close <= ema_100:
|
||||
return None
|
||||
if direction == "SHORT" and close >= ema_100:
|
||||
return None
|
||||
|
||||
# Price within 1.0 ATR of 100 EMA
|
||||
if abs(close - ema_100) > 1.0 * atr_val:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# REVERSAL PATTERN
|
||||
# ---------------------------------------------------------------
|
||||
body = abs(current["close"] - current["open"])
|
||||
full_range = current["high"] - current["low"]
|
||||
if full_range <= 0:
|
||||
return None
|
||||
|
||||
upper_wick = current["high"] - max(current["close"], current["open"])
|
||||
lower_wick = min(current["close"], current["open"]) - current["low"]
|
||||
close_position = (current["close"] - current["low"]) / full_range
|
||||
|
||||
has_reversal = False
|
||||
pattern = ""
|
||||
|
||||
if direction == "LONG":
|
||||
if body > 0 and lower_wick >= 2.0 * body and close_position >= 0.75:
|
||||
has_reversal = True
|
||||
pattern = "hammer"
|
||||
elif body / full_range > 0.60 and current["close"] > current["open"]:
|
||||
has_reversal = True
|
||||
pattern = "strong_bullish_close"
|
||||
else:
|
||||
if body > 0 and upper_wick >= 2.0 * body and close_position <= 0.25:
|
||||
has_reversal = True
|
||||
pattern = "shooting_star"
|
||||
elif body / full_range > 0.60 and current["close"] < current["open"]:
|
||||
has_reversal = True
|
||||
pattern = "strong_bearish_close"
|
||||
|
||||
if not has_reversal:
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# CONFIRMATION FILTERS
|
||||
# ---------------------------------------------------------------
|
||||
has_volume = False
|
||||
if "volume" in current.index:
|
||||
vol = current["volume"]
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.2 * vol_avg:
|
||||
has_volume = True
|
||||
if not has_volume:
|
||||
return None
|
||||
|
||||
# RSI optional
|
||||
has_rsi = False
|
||||
for j in range(max(0, idx - 2), idx + 1):
|
||||
bar_rsi = data.iloc[j].get("rsi_14", 50)
|
||||
if direction == "LONG" and bar_rsi < 40:
|
||||
has_rsi = True
|
||||
break
|
||||
elif direction == "SHORT" and bar_rsi > 60:
|
||||
has_rsi = True
|
||||
break
|
||||
|
||||
risk_pct = 0.015 if has_rsi else 0.01
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# ENTRY, SL, TP LEVELS
|
||||
# ---------------------------------------------------------------
|
||||
entry = close
|
||||
ema_200 = current.get("ema_200", np.nan)
|
||||
if np.isnan(ema_200):
|
||||
return None
|
||||
|
||||
if direction == "LONG":
|
||||
sl = ema_200 - 0.5 * atr_val
|
||||
else:
|
||||
sl = ema_200 + 0.5 * atr_val
|
||||
|
||||
if direction == "LONG":
|
||||
tp1 = entry + 4.0 * atr_val
|
||||
else:
|
||||
tp1 = entry - 4.0 * atr_val
|
||||
|
||||
tp2 = tp1
|
||||
|
||||
if direction == "LONG":
|
||||
tp3 = entry + 20.0 * atr_val
|
||||
else:
|
||||
tp3 = entry - 20.0 * atr_val
|
||||
|
||||
confirmations = 1 + (1 if has_rsi else 0)
|
||||
confluence = confirmations + 2
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp3,
|
||||
"confluence": min(confluence, 5),
|
||||
"entry_pattern": f"ema_bounce_{pattern}",
|
||||
"tp_splits": (0.60, 0.0, 0.40),
|
||||
"trail_atr_mult": 5.0,
|
||||
"max_bars": 120,
|
||||
"no_breakeven": False,
|
||||
"risk_pct": risk_pct,
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
"""
|
||||
Per-trade PDF report generator for fx-quant backtests.
|
||||
|
||||
Generates a multi-page PDF per strategy-pair:
|
||||
- Page 1: Strategy summary (metrics, equity curve, distributions)
|
||||
- Pages 2+: One page per trade (candlestick chart, indicators, info box)
|
||||
|
||||
Features:
|
||||
- H1 trendline projection on M15 chart (when htf_data available)
|
||||
- Confluence annotation arrow explaining trade trigger
|
||||
- Dark theme matching chart_trades.py
|
||||
|
||||
Usage:
|
||||
Standalone: python src/trade_pdf_report.py 1 # regenerate PDFs for strategy 1
|
||||
Standalone: python src/trade_pdf_report.py 1 3 5 # multiple strategies
|
||||
From code: generate_trade_pdf(strategy_id, pair, report, trade_log_df, data, htf_data)
|
||||
"""
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # Non-interactive backend — avoids GDI bitmap limits on Windows
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import mplfinance as mpf
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.backends.backend_pdf import PdfPages
|
||||
from matplotlib.lines import Line2D
|
||||
|
||||
plt.rcParams["figure.max_open_warning"] = 0 # Suppress warning; we close every figure
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from src.indicators.technical import (
|
||||
fit_trendline, swing_highs, swing_lows,
|
||||
)
|
||||
|
||||
RESULTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark theme (matches chart_trades.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
DARK_BG = "#1e1e1e"
|
||||
DARK_GRID = "#2a2a2a"
|
||||
TEXT_COLOR = "white"
|
||||
TEXT_DIM = "#aaaaaa"
|
||||
GREEN = "#00cc66"
|
||||
RED = "#ee4444"
|
||||
CYAN = "#00bcd4"
|
||||
BLUE = "#4488ff"
|
||||
ORANGE = "#ff9900"
|
||||
|
||||
MC = mpf.make_marketcolors(
|
||||
up="green", down="red",
|
||||
edge={"up": "green", "down": "red"},
|
||||
wick={"up": "green", "down": "red"},
|
||||
volume="in",
|
||||
)
|
||||
MPF_STYLE = mpf.make_mpf_style(
|
||||
marketcolors=MC,
|
||||
gridstyle=":",
|
||||
gridcolor=DARK_GRID,
|
||||
facecolor=DARK_BG,
|
||||
figcolor=DARK_BG,
|
||||
rc={
|
||||
"axes.labelcolor": TEXT_COLOR,
|
||||
"xtick.color": TEXT_COLOR,
|
||||
"ytick.color": TEXT_COLOR,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trendline overlay helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tz_align(ts, target_index):
|
||||
"""Align timezone of a timestamp to match the target index."""
|
||||
ts = pd.Timestamp(ts)
|
||||
if target_index.tz is None and ts.tz is not None:
|
||||
return ts.tz_localize(None)
|
||||
if target_index.tz is not None and ts.tz is None:
|
||||
return ts.tz_localize(target_index.tz)
|
||||
return ts
|
||||
|
||||
|
||||
def _compute_trendline_overlay(chart_data, entry_time, direction, htf_data):
|
||||
"""
|
||||
Detect the H1 trendline active at entry and project it onto the M15 chart.
|
||||
|
||||
Returns (tl_x, tl_y, tl_info) or None.
|
||||
tl_x: list of integer x-coordinates (mplfinance bar indices)
|
||||
tl_y: list of trendline prices at those x-coords
|
||||
tl_info: dict with slope, intercept, r_squared, touch_count
|
||||
"""
|
||||
if htf_data is None or len(htf_data) < 50:
|
||||
return None
|
||||
|
||||
entry_ts = _tz_align(entry_time, htf_data.index)
|
||||
n_valid = int(htf_data.index.searchsorted(entry_ts, side="left"))
|
||||
if n_valid < 50:
|
||||
return None
|
||||
|
||||
start = max(0, n_valid - 200)
|
||||
window = htf_data.iloc[start:n_valid]
|
||||
offset = start
|
||||
|
||||
# Detect support trendline for SHORT, resistance for LONG
|
||||
if direction == "SHORT":
|
||||
mask = swing_lows(window, lookback=5)
|
||||
indices = np.where(mask.values)[0]
|
||||
if len(indices) < 3:
|
||||
return None
|
||||
recent = indices[-8:]
|
||||
prices = window["low"].values[recent]
|
||||
else:
|
||||
mask = swing_highs(window, lookback=5)
|
||||
indices = np.where(mask.values)[0]
|
||||
if len(indices) < 3:
|
||||
return None
|
||||
recent = indices[-8:]
|
||||
prices = window["high"].values[recent]
|
||||
|
||||
tl = fit_trendline(recent, prices)
|
||||
if tl is None:
|
||||
return None
|
||||
|
||||
# Project trendline at each M15 bar in chart_data
|
||||
htf_ts = htf_data.index[:n_valid]
|
||||
tl_x = []
|
||||
tl_y = []
|
||||
|
||||
for i, ts in enumerate(chart_data.index):
|
||||
ts_aligned = _tz_align(ts, htf_ts)
|
||||
htf_idx = int(htf_ts.searchsorted(ts_aligned, side="right")) - 1
|
||||
if htf_idx < 0:
|
||||
htf_idx = 0
|
||||
window_idx = htf_idx - offset
|
||||
tl_price = tl["slope"] * window_idx + tl["intercept"]
|
||||
tl_x.append(i)
|
||||
tl_y.append(tl_price)
|
||||
|
||||
return tl_x, tl_y, tl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confluence annotation builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_confluence_annotation(trade_row, strategy_id):
|
||||
"""
|
||||
Build a 1-line annotation explaining why the trade triggered.
|
||||
|
||||
Returns annotation string for the arrow label.
|
||||
"""
|
||||
direction = trade_row["signal_direction"]
|
||||
confluence = int(trade_row.get("confluence_score", 0))
|
||||
rsi = trade_row.get("rsi_at_entry", 50)
|
||||
macd_hist = trade_row.get("macd_hist_at_entry", 0)
|
||||
engulf_type = "Bullish" if direction == "LONG" else "Bearish"
|
||||
|
||||
if strategy_id == 1:
|
||||
# S1: Trendline Breakout-Retest
|
||||
# Confluence factors: R²>0.90, touches>=4, vol>avg, RSI 40-60, MACD confirms
|
||||
pattern = trade_row.get("entry_pattern", "engulfing")
|
||||
pattern_label = {"engulfing": "engulfing candle",
|
||||
"pin_bar": "pin bar rejection",
|
||||
"strong_close": "strong directional close"
|
||||
}.get(pattern, pattern or "engulfing candle")
|
||||
|
||||
factors = []
|
||||
rsi_neutral = 40 <= rsi <= 60
|
||||
macd_confirms = (direction == "LONG" and macd_hist > 0) or \
|
||||
(direction == "SHORT" and macd_hist < 0)
|
||||
|
||||
if rsi_neutral:
|
||||
factors.append(f"RSI neutral at {rsi:.0f}")
|
||||
if macd_confirms:
|
||||
factors.append("MACD confirming momentum")
|
||||
|
||||
detail = ", ".join(factors) if factors else "strong trendline fit"
|
||||
return (f"{engulf_type} {pattern_label} at H1 trendline retest with "
|
||||
f"{detail} ({confluence}/5 confluence)")
|
||||
|
||||
elif strategy_id == 3:
|
||||
return (f"{engulf_type} breakout at key S/R level with "
|
||||
f"{confluence}/5 confluence")
|
||||
|
||||
elif strategy_id == 4:
|
||||
return (f"EMA ribbon aligned {direction.lower()} with "
|
||||
f"RSI at {rsi:.0f} ({confluence}/5 confluence)")
|
||||
|
||||
elif strategy_id == 5:
|
||||
return (f"Momentum exhaustion {direction.lower()} signal with "
|
||||
f"RSI at {rsi:.0f} ({confluence}/5 confluence)")
|
||||
|
||||
# Generic fallback
|
||||
return f"{direction} signal with {confluence} confluence factors"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _draw_summary_page(pdf, report, trade_log_df):
|
||||
"""Draw strategy summary as the first page of the PDF."""
|
||||
fig = plt.figure(figsize=(11, 8.5), facecolor=DARK_BG)
|
||||
|
||||
# Title
|
||||
strat_name = report.get("strategy_name", "Unknown")
|
||||
pair = report.get("pair", "")
|
||||
fig.suptitle(f"{strat_name} — {pair}", color=TEXT_COLOR,
|
||||
fontsize=16, fontweight="bold", y=0.97)
|
||||
|
||||
total = report.get("total_trades", 0)
|
||||
if total == 0:
|
||||
fig.text(0.5, 0.5, "No trades generated", color=TEXT_DIM,
|
||||
fontsize=14, ha="center", va="center")
|
||||
pdf.savefig(fig, facecolor=DARK_BG)
|
||||
plt.close(fig)
|
||||
return
|
||||
|
||||
# --- Metrics table (left side) ---
|
||||
ax_table = fig.add_axes([0.04, 0.42, 0.42, 0.50])
|
||||
ax_table.set_facecolor(DARK_BG)
|
||||
ax_table.axis("off")
|
||||
|
||||
metrics = [
|
||||
("Total Trades", f"{total}"),
|
||||
("Win Rate", f"{report.get('win_rate_pct', 0):.1f}%"),
|
||||
("Avg R:R", f"{report.get('avg_rr', 0):.2f}"),
|
||||
("Profit Factor", f"{report.get('profit_factor', 0):.2f}"),
|
||||
("Sharpe Ratio", f"{report.get('sharpe_ratio', 0):.2f}"),
|
||||
("Max Drawdown", f"{report.get('max_drawdown_pct', 0):.2f}%"),
|
||||
("Expectancy", f"{report.get('expectancy_pips', 0):.2f} pips"),
|
||||
("Total PnL", f"{report.get('total_pnl_pips', 0):.1f} pips / ${report.get('total_pnl_dollars', 0):,.2f}"),
|
||||
("Avg Win", f"{report.get('avg_win_pips', 0):.1f} pips"),
|
||||
("Avg Loss", f"{report.get('avg_loss_pips', 0):.1f} pips"),
|
||||
("Best Trade", f"{report.get('best_trade_pips', 0):.1f} pips"),
|
||||
("Worst Trade", f"{report.get('worst_trade_pips', 0):.1f} pips"),
|
||||
("Avg Hold Time", f"{report.get('avg_hold_time_minutes', 0):.0f} min"),
|
||||
("Max Consec Wins", f"{report.get('max_consecutive_wins', 0)}"),
|
||||
("Max Consec Losses", f"{report.get('max_consecutive_losses', 0)}"),
|
||||
("Final Equity", f"${report.get('final_equity', 0):,.2f}"),
|
||||
]
|
||||
|
||||
y_pos = 1.0
|
||||
for label, value in metrics:
|
||||
ax_table.text(0.0, y_pos, label, color=TEXT_DIM, fontsize=8.5,
|
||||
fontfamily="monospace", va="top")
|
||||
ax_table.text(0.65, y_pos, value, color=TEXT_COLOR, fontsize=8.5,
|
||||
fontfamily="monospace", va="top", fontweight="bold")
|
||||
y_pos -= 0.065
|
||||
|
||||
# --- Equity curve (top right) ---
|
||||
if len(trade_log_df) > 0:
|
||||
ax_eq = fig.add_axes([0.55, 0.60, 0.40, 0.28])
|
||||
ax_eq.set_facecolor(DARK_BG)
|
||||
|
||||
starting_eq = report.get("starting_equity", 100000)
|
||||
cumulative = starting_eq + trade_log_df["pnl_dollars"].cumsum()
|
||||
equity_series = pd.concat([pd.Series([starting_eq]), cumulative]).reset_index(drop=True)
|
||||
|
||||
ax_eq.plot(equity_series, color=CYAN, linewidth=1.2)
|
||||
ax_eq.axhline(y=starting_eq, color=TEXT_DIM, linewidth=0.5, linestyle="--")
|
||||
ax_eq.fill_between(range(len(equity_series)), starting_eq, equity_series,
|
||||
where=equity_series >= starting_eq, alpha=0.15, color=GREEN)
|
||||
ax_eq.fill_between(range(len(equity_series)), starting_eq, equity_series,
|
||||
where=equity_series < starting_eq, alpha=0.15, color=RED)
|
||||
ax_eq.set_title("Equity Curve", color=TEXT_COLOR, fontsize=9)
|
||||
ax_eq.tick_params(colors=TEXT_DIM, labelsize=7)
|
||||
for spine in ax_eq.spines.values():
|
||||
spine.set_color(DARK_GRID)
|
||||
|
||||
# --- Win/Loss distribution (bottom left) ---
|
||||
if len(trade_log_df) > 0:
|
||||
ax_dist = fig.add_axes([0.06, 0.07, 0.38, 0.28])
|
||||
ax_dist.set_facecolor(DARK_BG)
|
||||
|
||||
pnl_pips = trade_log_df["pnl_pips"]
|
||||
win_pnl = pnl_pips[pnl_pips > 0]
|
||||
loss_pnl = pnl_pips[pnl_pips <= 0]
|
||||
|
||||
bins = np.linspace(pnl_pips.min(), pnl_pips.max(), min(20, max(5, total // 2)))
|
||||
if len(win_pnl) > 0:
|
||||
ax_dist.hist(win_pnl, bins=bins, color=GREEN, alpha=0.7, label="Wins")
|
||||
if len(loss_pnl) > 0:
|
||||
ax_dist.hist(loss_pnl, bins=bins, color=RED, alpha=0.7, label="Losses")
|
||||
ax_dist.axvline(x=0, color=TEXT_DIM, linewidth=0.5, linestyle="--")
|
||||
ax_dist.set_title("PnL Distribution (pips)", color=TEXT_COLOR, fontsize=9)
|
||||
ax_dist.legend(fontsize=7, facecolor=DARK_GRID, edgecolor=DARK_GRID,
|
||||
labelcolor=TEXT_COLOR)
|
||||
ax_dist.tick_params(colors=TEXT_DIM, labelsize=7)
|
||||
for spine in ax_dist.spines.values():
|
||||
spine.set_color(DARK_GRID)
|
||||
|
||||
# --- Exit reasons breakdown (bottom right) ---
|
||||
exit_reasons = report.get("exit_reasons", {})
|
||||
if exit_reasons:
|
||||
ax_exit = fig.add_axes([0.55, 0.07, 0.40, 0.28])
|
||||
ax_exit.set_facecolor(DARK_BG)
|
||||
|
||||
labels = list(exit_reasons.keys())
|
||||
counts = list(exit_reasons.values())
|
||||
bar_colors = []
|
||||
for label in labels:
|
||||
if "TP3" in label:
|
||||
bar_colors.append(GREEN)
|
||||
elif "SL" in label and "TP" not in label:
|
||||
bar_colors.append(RED)
|
||||
elif "TIME" in label or "END" in label:
|
||||
bar_colors.append(TEXT_DIM)
|
||||
else:
|
||||
bar_colors.append(CYAN)
|
||||
|
||||
bars = ax_exit.barh(labels, counts, color=bar_colors, height=0.6)
|
||||
ax_exit.set_title("Exit Reasons", color=TEXT_COLOR, fontsize=9)
|
||||
ax_exit.tick_params(colors=TEXT_DIM, labelsize=7)
|
||||
for spine in ax_exit.spines.values():
|
||||
spine.set_color(DARK_GRID)
|
||||
# Value labels on bars
|
||||
for bar, count in zip(bars, counts):
|
||||
ax_exit.text(bar.get_width() + 0.2, bar.get_y() + bar.get_height() / 2,
|
||||
str(count), color=TEXT_COLOR, fontsize=7, va="center")
|
||||
|
||||
pdf.savefig(fig, facecolor=DARK_BG)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-trade page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _draw_trade_page(pdf, trade_row, trade_num, total_trades, data, pair,
|
||||
htf_data=None, strategy_id=0):
|
||||
"""Draw a single trade page with candlestick chart, RSI, and info box."""
|
||||
entry_time = pd.Timestamp(trade_row["timestamp"])
|
||||
exit_time = pd.Timestamp(trade_row["exit_time"])
|
||||
direction = trade_row["signal_direction"]
|
||||
entry_price = trade_row["entry_price"]
|
||||
exit_price = trade_row["exit_price"]
|
||||
sl_price = trade_row["sl_price"]
|
||||
tp1_price = trade_row["tp1_price"]
|
||||
tp2_price = trade_row["tp2_price"]
|
||||
tp3_price = trade_row["tp3_price"]
|
||||
pnl_pips = trade_row["pnl_pips"]
|
||||
exit_reason = trade_row["exit_reason"]
|
||||
|
||||
# Find bar indices for entry and exit
|
||||
try:
|
||||
entry_idx = data.index.get_indexer([entry_time], method="nearest")[0]
|
||||
except Exception:
|
||||
entry_idx = 0
|
||||
try:
|
||||
exit_idx = data.index.get_indexer([exit_time], method="nearest")[0]
|
||||
except Exception:
|
||||
exit_idx = min(entry_idx + 50, len(data) - 1)
|
||||
|
||||
# Window: 50 bars before entry to exit + 10 bars after
|
||||
start_idx = max(0, entry_idx - 50)
|
||||
end_idx = min(len(data) - 1, exit_idx + 10)
|
||||
|
||||
# Ensure minimum chart width
|
||||
if end_idx - start_idx < 20:
|
||||
end_idx = min(len(data) - 1, start_idx + 20)
|
||||
|
||||
chart_data = data.iloc[start_idx:end_idx + 1].copy()
|
||||
|
||||
if len(chart_data) < 3:
|
||||
return # skip if not enough data
|
||||
|
||||
# --- Build addplots ---
|
||||
addplots = []
|
||||
|
||||
# EMA 50 overlay
|
||||
if "ema_50" in chart_data.columns:
|
||||
ema_50 = chart_data["ema_50"]
|
||||
if ema_50.notna().any():
|
||||
addplots.append(mpf.make_addplot(ema_50, color=CYAN, width=1.2,
|
||||
panel=0))
|
||||
|
||||
# Entry marker
|
||||
entry_markers = pd.Series(np.nan, index=chart_data.index)
|
||||
entry_chart_x = None # track entry x-coord for annotation
|
||||
if entry_time in chart_data.index:
|
||||
entry_markers.at[entry_time] = entry_price
|
||||
entry_chart_x = chart_data.index.get_loc(entry_time)
|
||||
elif entry_idx >= start_idx and entry_idx <= end_idx:
|
||||
entry_markers.iloc[entry_idx - start_idx] = entry_price
|
||||
entry_chart_x = entry_idx - start_idx
|
||||
|
||||
if entry_markers.notna().any():
|
||||
marker_char = "^" if direction == "LONG" else "v"
|
||||
marker_color = GREEN if direction == "LONG" else RED
|
||||
addplots.append(mpf.make_addplot(
|
||||
entry_markers, type="scatter", marker=marker_char, markersize=120,
|
||||
color=marker_color, edgecolors="white", linewidths=0.8, panel=0))
|
||||
|
||||
# Exit marker
|
||||
exit_markers = pd.Series(np.nan, index=chart_data.index)
|
||||
if exit_time in chart_data.index:
|
||||
exit_markers.at[exit_time] = exit_price
|
||||
elif exit_idx >= start_idx and exit_idx <= end_idx:
|
||||
exit_markers.iloc[exit_idx - start_idx] = exit_price
|
||||
|
||||
if exit_markers.notna().any():
|
||||
addplots.append(mpf.make_addplot(
|
||||
exit_markers, type="scatter", marker="X", markersize=100,
|
||||
color=BLUE, edgecolors="white", linewidths=0.8, panel=0))
|
||||
|
||||
# RSI subplot
|
||||
if "rsi_14" in chart_data.columns:
|
||||
rsi_data = chart_data["rsi_14"]
|
||||
if rsi_data.notna().any():
|
||||
addplots.append(mpf.make_addplot(rsi_data, panel=2, color="yellow",
|
||||
width=0.9, ylabel="RSI"))
|
||||
|
||||
# --- Plot ---
|
||||
pnl_sign = "+" if pnl_pips >= 0 else ""
|
||||
title = (f"Trade #{trade_num}/{total_trades} — "
|
||||
f"{direction} {pair} @ {entry_price:.5f} | "
|
||||
f"{entry_time.strftime('%Y-%m-%d %H:%M')} | "
|
||||
f"{pnl_sign}{pnl_pips:.1f} pips ({exit_reason})")
|
||||
|
||||
try:
|
||||
fig, axes = mpf.plot(
|
||||
chart_data, type="candle", style=MPF_STYLE,
|
||||
addplot=addplots if addplots else None,
|
||||
volume=False,
|
||||
figsize=(11, 8.5),
|
||||
tight_layout=False,
|
||||
returnfig=True,
|
||||
panel_ratios=(7, 0.3, 1.5),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback without RSI panel if it fails
|
||||
addplots_filtered = []
|
||||
for ap in addplots:
|
||||
try:
|
||||
if ap.get("panel", 0) != 2:
|
||||
addplots_filtered.append(ap)
|
||||
except Exception:
|
||||
addplots_filtered.append(ap)
|
||||
try:
|
||||
fig, axes = mpf.plot(
|
||||
chart_data, type="candle", style=MPF_STYLE,
|
||||
addplot=addplots_filtered if addplots_filtered else None,
|
||||
volume=False,
|
||||
figsize=(11, 8.5),
|
||||
tight_layout=False,
|
||||
returnfig=True,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" WARNING: Could not plot trade #{trade_num}: {e}")
|
||||
return
|
||||
|
||||
ax_main = axes[0]
|
||||
ax_main.set_title(title, color=TEXT_COLOR, fontsize=10, pad=10)
|
||||
|
||||
# --- Trendline overlay ---
|
||||
has_trendline = False
|
||||
tl_result = _compute_trendline_overlay(chart_data, entry_time, direction,
|
||||
htf_data)
|
||||
if tl_result is not None:
|
||||
tl_x, tl_y, tl_info = tl_result
|
||||
ax_main.plot(tl_x, tl_y, color=ORANGE, linewidth=1.8,
|
||||
linestyle="--", alpha=0.85, zorder=5)
|
||||
# Label the trendline
|
||||
label_side = "support" if direction == "SHORT" else "resistance"
|
||||
r2 = tl_info.get("r_squared", 0)
|
||||
touches = tl_info.get("touch_count", 0)
|
||||
ax_main.text(tl_x[-1] + 0.5, tl_y[-1],
|
||||
f" H1 {label_side}\n R\u00b2={r2:.2f}, {touches}T",
|
||||
color=ORANGE, fontsize=6.5, va="center", fontweight="bold")
|
||||
has_trendline = True
|
||||
|
||||
# --- Confluence annotation arrow ---
|
||||
if entry_chart_x is not None:
|
||||
annotation_text = _build_confluence_annotation(trade_row, strategy_id)
|
||||
|
||||
# Position the arrow text above for LONG, below for SHORT
|
||||
atr_val = trade_row.get("atr_at_entry", 0)
|
||||
if atr_val <= 0:
|
||||
atr_val = abs(tp1_price - entry_price) * 0.3
|
||||
|
||||
if direction == "LONG":
|
||||
text_y = entry_price - 2.5 * atr_val
|
||||
arrow_y = entry_price - 0.3 * atr_val
|
||||
else:
|
||||
text_y = entry_price + 2.5 * atr_val
|
||||
arrow_y = entry_price + 0.3 * atr_val
|
||||
|
||||
# Offset text horizontally to avoid candle overlap
|
||||
text_x = max(0, entry_chart_x - 15)
|
||||
|
||||
ax_main.annotate(
|
||||
annotation_text,
|
||||
xy=(entry_chart_x, arrow_y),
|
||||
xytext=(text_x, text_y),
|
||||
fontsize=7, color=ORANGE, fontweight="bold",
|
||||
arrowprops=dict(
|
||||
arrowstyle="->",
|
||||
color=ORANGE,
|
||||
linewidth=1.5,
|
||||
connectionstyle="arc3,rad=0.2",
|
||||
),
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor=DARK_BG,
|
||||
edgecolor=ORANGE, alpha=0.9),
|
||||
zorder=10,
|
||||
)
|
||||
|
||||
# Draw horizontal SL/TP lines
|
||||
xlim = ax_main.get_xlim()
|
||||
ax_main.hlines(y=sl_price, xmin=xlim[0], xmax=xlim[1],
|
||||
colors=RED, linestyles="dashed", linewidth=0.8, alpha=0.7)
|
||||
ax_main.hlines(y=tp1_price, xmin=xlim[0], xmax=xlim[1],
|
||||
colors=GREEN, linestyles="dashed", linewidth=0.7, alpha=0.8)
|
||||
ax_main.hlines(y=tp2_price, xmin=xlim[0], xmax=xlim[1],
|
||||
colors=GREEN, linestyles="dashed", linewidth=0.7, alpha=0.6)
|
||||
ax_main.hlines(y=tp3_price, xmin=xlim[0], xmax=xlim[1],
|
||||
colors=GREEN, linestyles="dashed", linewidth=0.7, alpha=0.4)
|
||||
|
||||
# Label SL/TP on right edge
|
||||
ax_main.text(xlim[1], sl_price, " SL", color=RED, fontsize=7,
|
||||
va="center", fontweight="bold")
|
||||
ax_main.text(xlim[1], tp1_price, " TP1", color=GREEN, fontsize=7,
|
||||
va="center", alpha=0.8)
|
||||
ax_main.text(xlim[1], tp2_price, " TP2", color=GREEN, fontsize=7,
|
||||
va="center", alpha=0.6)
|
||||
ax_main.text(xlim[1], tp3_price, " TP3", color=GREEN, fontsize=7,
|
||||
va="center", alpha=0.4)
|
||||
|
||||
# RSI reference lines (40/60 zone)
|
||||
for ax in axes:
|
||||
if hasattr(ax, 'get_ylabel') and ax.get_ylabel() == "RSI":
|
||||
ax.axhline(y=40, color=TEXT_DIM, linewidth=0.5, linestyle="--", alpha=0.5)
|
||||
ax.axhline(y=60, color=TEXT_DIM, linewidth=0.5, linestyle="--", alpha=0.5)
|
||||
ax.axhspan(40, 60, alpha=0.05, color="yellow")
|
||||
ax.set_ylim(0, 100)
|
||||
break
|
||||
|
||||
# --- Info box at bottom ---
|
||||
hold_time = trade_row.get("hold_time_minutes", 0)
|
||||
pnl_dollars = trade_row.get("pnl_dollars", 0)
|
||||
confluence = trade_row.get("confluence_score", 0)
|
||||
session = trade_row.get("session", "")
|
||||
atr_entry = trade_row.get("atr_at_entry", 0)
|
||||
rsi_entry = trade_row.get("rsi_at_entry", 0)
|
||||
macd_hist = trade_row.get("macd_hist_at_entry", 0)
|
||||
lot_size = trade_row.get("lot_size", 0)
|
||||
|
||||
info_text = (
|
||||
f"Dir: {direction} Entry: {entry_price:.5f} Exit: {exit_price:.5f} "
|
||||
f"SL: {sl_price:.5f} TP1: {tp1_price:.5f} TP2: {tp2_price:.5f} TP3: {tp3_price:.5f}\n"
|
||||
f"PnL: {pnl_sign}{pnl_pips:.1f} pips (${pnl_dollars:,.2f}) "
|
||||
f"Hold: {hold_time} min Lots: {lot_size:,.0f} "
|
||||
f"Confluence: {confluence} Session: {session}\n"
|
||||
f"ATR: {atr_entry:.5f} RSI: {rsi_entry:.1f} MACD Hist: {macd_hist:.6f} "
|
||||
f"Exit: {exit_reason}"
|
||||
)
|
||||
|
||||
fig.text(0.05, 0.02, info_text, color=TEXT_DIM, fontsize=7.5,
|
||||
fontfamily="monospace", va="bottom",
|
||||
bbox=dict(boxstyle="round,pad=0.4", facecolor=DARK_GRID,
|
||||
edgecolor="#444444", alpha=0.9))
|
||||
|
||||
# Legend
|
||||
legend_elements = [
|
||||
Line2D([0], [0], color=CYAN, lw=1.2, label="EMA 50"),
|
||||
Line2D([0], [0], marker="^" if direction == "LONG" else "v",
|
||||
color=GREEN if direction == "LONG" else RED,
|
||||
lw=0, markersize=8, label="Entry"),
|
||||
Line2D([0], [0], marker="X", color=BLUE, lw=0, markersize=8, label="Exit"),
|
||||
Line2D([0], [0], color=RED, lw=0.8, linestyle="dashed", label="SL"),
|
||||
Line2D([0], [0], color=GREEN, lw=0.8, linestyle="dashed", label="TP"),
|
||||
]
|
||||
if has_trendline:
|
||||
legend_elements.append(
|
||||
Line2D([0], [0], color=ORANGE, lw=1.8, linestyle="--",
|
||||
label="H1 Trendline"))
|
||||
ax_main.legend(handles=legend_elements, loc="upper left", fontsize=7,
|
||||
facecolor=DARK_GRID, edgecolor="#444444", labelcolor=TEXT_COLOR)
|
||||
|
||||
pdf.savefig(fig, facecolor=DARK_BG)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_trade_pdf(strategy_id, pair, report, trade_log_df, data,
|
||||
htf_data=None):
|
||||
"""
|
||||
Generate a multi-page PDF with summary + per-trade charts.
|
||||
|
||||
Args:
|
||||
strategy_id: Strategy number (1-5).
|
||||
pair: Currency pair string e.g. 'GBP_AUD'.
|
||||
report: Performance report dict from backtester.
|
||||
trade_log_df: DataFrame of trade records (from CSV or backtester).
|
||||
data: Primary timeframe OHLCV DataFrame with indicators.
|
||||
htf_data: Higher timeframe data (optional, for trendline overlay).
|
||||
"""
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
prefix = f"S{strategy_id}_{pair}"
|
||||
pdf_path = os.path.join(RESULTS_DIR, f"{prefix}_trades.pdf")
|
||||
|
||||
total = report.get("total_trades", 0)
|
||||
print(f" Generating PDF: {prefix} ({total} trades)...", end=" ")
|
||||
|
||||
with PdfPages(pdf_path) as pdf:
|
||||
# Page 1: Summary
|
||||
_draw_summary_page(pdf, report, trade_log_df)
|
||||
|
||||
# Pages 2+: One per trade
|
||||
if len(trade_log_df) > 0:
|
||||
for idx, (_, trade_row) in enumerate(trade_log_df.iterrows(), 1):
|
||||
_draw_trade_page(pdf, trade_row, idx, total, data, pair,
|
||||
htf_data=htf_data, strategy_id=strategy_id)
|
||||
# Periodic cleanup to avoid GDI/memory exhaustion on large runs
|
||||
if idx % 10 == 0:
|
||||
plt.close("all")
|
||||
gc.collect()
|
||||
|
||||
print(f"saved to {pdf_path}")
|
||||
return pdf_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Regenerate PDFs from saved CSV/JSON files without re-running backtests."""
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.strategies_pkg import STRATEGIES, STRATEGY_PAIRS, STRATEGY_TIMEFRAMES
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||
"data", "processed")
|
||||
|
||||
selected = None
|
||||
if len(sys.argv) > 1:
|
||||
selected = [int(x) for x in sys.argv[1:]]
|
||||
|
||||
print("=" * 60)
|
||||
print("PDF REPORT GENERATOR (standalone)")
|
||||
print("=" * 60)
|
||||
|
||||
for strategy_id in sorted(STRATEGIES.keys()):
|
||||
if selected and strategy_id not in selected:
|
||||
continue
|
||||
|
||||
pairs = STRATEGY_PAIRS[strategy_id]
|
||||
tf_config = STRATEGY_TIMEFRAMES[strategy_id]
|
||||
primary_tf = tf_config["primary"]
|
||||
filter_tf = tf_config["filter"]
|
||||
|
||||
for pair in pairs:
|
||||
prefix = f"S{strategy_id}_{pair}"
|
||||
|
||||
# Load report JSON
|
||||
report_path = os.path.join(RESULTS_DIR, f"{prefix}_report.json")
|
||||
if not os.path.exists(report_path):
|
||||
print(f" SKIP {prefix}: no report JSON")
|
||||
continue
|
||||
with open(report_path) as f:
|
||||
report = json.load(f)
|
||||
|
||||
# Load trade log CSV
|
||||
csv_path = os.path.join(RESULTS_DIR, f"{prefix}_trades.csv")
|
||||
if not os.path.exists(csv_path):
|
||||
print(f" SKIP {prefix}: no trade CSV")
|
||||
continue
|
||||
trade_log_df = pd.read_csv(csv_path)
|
||||
|
||||
if len(trade_log_df) == 0:
|
||||
print(f" SKIP {prefix}: no trades")
|
||||
continue
|
||||
|
||||
# Load primary data with indicators
|
||||
data_path = os.path.join(PROCESSED_DIR, f"{pair}_{primary_tf}.csv")
|
||||
if not os.path.exists(data_path):
|
||||
print(f" SKIP {prefix}: no {primary_tf} data file")
|
||||
continue
|
||||
data = pd.read_csv(data_path, index_col=0, parse_dates=True)
|
||||
data.index.name = "timestamp"
|
||||
data = compute_all_indicators(data)
|
||||
|
||||
# Load HTF data if available
|
||||
htf_data = None
|
||||
if filter_tf:
|
||||
htf_path = os.path.join(PROCESSED_DIR, f"{pair}_{filter_tf}.csv")
|
||||
if os.path.exists(htf_path):
|
||||
htf_data = pd.read_csv(htf_path, index_col=0, parse_dates=True)
|
||||
htf_data.index.name = "timestamp"
|
||||
htf_data = compute_all_indicators(htf_data)
|
||||
|
||||
generate_trade_pdf(strategy_id, pair, report, trade_log_df,
|
||||
data, htf_data)
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user