mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-07 15:47:45 +00:00
Phase 1 complete: S7-S9 Smart Money strategies, expanded pair testing, consolidated scorecard
- S7 Liquidity Sweep: built, tested across 6 pairs, tight SL (1.0 ATR) on GBP_JPY is Phase 2 candidate (107 trades, OOS PF 1.39, gen ratio 1.81) - S8 Order Block: built, tested on GBP_JPY (watchlist, 32 trades, OOS PF 1.55) - S9 London Session: built, tested across 8 pairs with filter experiments GBP_USD (OOS PF 1.45) and GBP_AUD filtered (OOS PF 1.94) advance to Phase 2 - Added OBV indicator to technical.py - Added GBP_NZD to engine spread/pip config - Standalone OANDA fetcher (bypasses Supabase dependency) - Fetched EUR_GBP, EUR_USD, GBP_NZD H1 data (2021-2023) - Consolidated STRATEGY_LEARNINGS.md with full Phase 1 scorecard and 11 design principles - Phase 2 roster: S7/GBP_JPY, S9/GBP_USD, S9F/GBP_AUD, S4-F/EUR_AUD, S3/GBP_JPY Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""Quick S9 trade analysis for a single pair — runs backtest and analyzes trade log."""
|
||||
import os, sys, io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from src.indicators.technical import compute_all_indicators
|
||||
from src.backtester.engine import Backtester
|
||||
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||
|
||||
PROCESSED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "processed")
|
||||
|
||||
pair = sys.argv[1] if len(sys.argv) > 1 else "GBP_AUD"
|
||||
|
||||
# Load and run
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_H1.csv")
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
df = compute_all_indicators(df)
|
||||
|
||||
bt = Backtester(data=df, strategy=S9_London_Session(), pair=pair, starting_equity=100_000.0, htf_data=df.copy())
|
||||
bt.run()
|
||||
trades = bt.get_trade_log_df()
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"S9 TRADE ANALYSIS — {pair} ({len(trades)} trades)")
|
||||
print(f"{'='*70}")
|
||||
|
||||
if len(trades) == 0:
|
||||
print("No trades!")
|
||||
sys.exit(0)
|
||||
|
||||
trades["win"] = trades["pnl_pips"] > 0
|
||||
|
||||
# 1. Exit reason breakdown
|
||||
print("\n1. EXIT REASON BREAKDOWN")
|
||||
for reason, grp in trades.groupby("exit_reason"):
|
||||
n = len(grp)
|
||||
wr = grp["win"].mean() * 100
|
||||
avg_pnl = grp["pnl_pips"].mean()
|
||||
print(f" {reason:<12} {n:>4} trades ({n/len(trades)*100:5.1f}%) | WR {wr:5.1f}% | Avg PnL {avg_pnl:+7.1f}p")
|
||||
|
||||
# 2. Direction
|
||||
print("\n2. WIN RATE BY DIRECTION")
|
||||
for d, grp in trades.groupby("signal_direction"):
|
||||
print(f" {d:<6} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 3. Day of week
|
||||
print("\n3. WIN RATE BY DAY OF WEEK")
|
||||
trades["dow"] = pd.to_datetime(trades["timestamp"]).dt.day_name()
|
||||
for day in ["Monday","Tuesday","Wednesday","Thursday","Friday"]:
|
||||
grp = trades[trades["dow"] == day]
|
||||
if len(grp) > 0:
|
||||
print(f" {day:<10} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 4. Hold time
|
||||
print("\n4. HOLD TIME (minutes)")
|
||||
winners = trades[trades["win"]]
|
||||
losers = trades[~trades["win"]]
|
||||
print(f" Winners: avg {winners['hold_time_minutes'].mean():.0f}m, median {winners['hold_time_minutes'].median():.0f}m")
|
||||
print(f" Losers: avg {losers['hold_time_minutes'].mean():.0f}m, median {losers['hold_time_minutes'].median():.0f}m")
|
||||
|
||||
# 5. Confluence
|
||||
print("\n5. CONFLUENCE SCORE")
|
||||
for score, grp in trades.groupby("confluence_score"):
|
||||
print(f" Score {score}: {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 6. Entry hour
|
||||
print("\n6. ENTRY HOUR")
|
||||
for h, grp in trades.groupby("hour_of_day"):
|
||||
print(f" Hour {h:>2}: {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 7. RSI zones
|
||||
print("\n7. RSI AT ENTRY")
|
||||
bins = [0, 30, 40, 50, 60, 70, 100]
|
||||
labels = ["<30", "30-40", "40-50", "50-60", "60-70", "70+"]
|
||||
trades["rsi_bin"] = pd.cut(trades["rsi_at_entry"], bins=bins, labels=labels, include_lowest=True)
|
||||
for b in labels:
|
||||
grp = trades[trades["rsi_bin"] == b]
|
||||
if len(grp) > 0:
|
||||
print(f" RSI {b:<6} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# RSI neutral vs momentum
|
||||
neutral = trades[(trades["rsi_at_entry"] >= 40) & (trades["rsi_at_entry"] <= 60)]
|
||||
momentum = trades[(trades["rsi_at_entry"] < 40) | (trades["rsi_at_entry"] > 60)]
|
||||
print(f" RSI 40-60 (neutral): {len(neutral):>4} trades, WR {neutral['win'].mean()*100:5.1f}%, Avg PnL {neutral['pnl_pips'].mean():+7.1f}p")
|
||||
print(f" RSI outside (momentum): {len(momentum):>4} trades, WR {momentum['win'].mean()*100:5.1f}%, Avg PnL {momentum['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 8. ADX buckets
|
||||
print("\n8. ADX AT ENTRY")
|
||||
adx_bins = [0, 20, 25, 30, 40, 100]
|
||||
adx_labels = ["<20", "20-25", "25-30", "30-40", "40+"]
|
||||
trades["adx_bin"] = pd.cut(trades["adx_at_entry"], bins=adx_bins, labels=adx_labels, include_lowest=True)
|
||||
for b in adx_labels:
|
||||
grp = trades[trades["adx_bin"] == b]
|
||||
if len(grp) > 0:
|
||||
print(f" ADX {b:<6} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 9. EMA50 distance
|
||||
print("\n9. DISTANCE FROM EMA50 (absolute pips)")
|
||||
trades["ema_dist_abs"] = trades["distance_from_ema50_pips"].abs()
|
||||
dist_bins = [0, 20, 40, 60, 100, 500]
|
||||
dist_labels = ["0-20", "20-40", "40-60", "60-100", "100+"]
|
||||
trades["dist_bin"] = pd.cut(trades["ema_dist_abs"], bins=dist_bins, labels=dist_labels, include_lowest=True)
|
||||
for b in dist_labels:
|
||||
grp = trades[trades["dist_bin"] == b]
|
||||
if len(grp) > 0:
|
||||
print(f" {b:<8} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 10. Candle body ratio
|
||||
print("\n10. CANDLE BODY RATIO")
|
||||
br_bins = [0, 0.3, 0.5, 0.7, 1.01]
|
||||
br_labels = ["<0.3", "0.3-0.5", "0.5-0.7", "0.7+"]
|
||||
trades["br_bin"] = pd.cut(trades["candle_body_ratio"], bins=br_bins, labels=br_labels, include_lowest=True)
|
||||
for b in br_labels:
|
||||
grp = trades[trades["br_bin"] == b]
|
||||
if len(grp) > 0:
|
||||
print(f" {b:<8} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# 11. Spread as % of avg win
|
||||
avg_win_pips = winners["pnl_pips"].mean() if len(winners) > 0 else 0
|
||||
avg_spread = trades["spread_at_entry"].mean()
|
||||
print(f"\n11. SPREAD IMPACT")
|
||||
print(f" Avg spread: {avg_spread:.1f} pips")
|
||||
print(f" Avg win: {avg_win_pips:.1f} pips")
|
||||
print(f" Spread/win: {avg_spread/avg_win_pips*100:.1f}%" if avg_win_pips > 0 else " N/A")
|
||||
|
||||
# 12. Yearly breakdown
|
||||
print("\n12. YEARLY BREAKDOWN")
|
||||
trades["year"] = pd.to_datetime(trades["timestamp"]).dt.year
|
||||
for y, grp in trades.groupby("year"):
|
||||
gw = grp[grp["pnl_pips"] > 0]["pnl_pips"].sum()
|
||||
gl = abs(grp[grp["pnl_pips"] < 0]["pnl_pips"].sum())
|
||||
pf = gw / gl if gl > 0 else 0
|
||||
print(f" {y}: {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, PF {pf:.2f}, PnL {grp['pnl_pips'].sum():+8.1f}p")
|
||||
|
||||
# 13. Session (if available)
|
||||
if "session" in trades.columns:
|
||||
print("\n13. SESSION")
|
||||
for s, grp in trades.groupby("session"):
|
||||
print(f" {s:<10} {len(grp):>4} trades, WR {grp['win'].mean()*100:5.1f}%, Avg PnL {grp['pnl_pips'].mean():+7.1f}p")
|
||||
|
||||
# Save trade log
|
||||
out = os.path.join(os.path.dirname(os.path.dirname(__file__)), "results", "phase1", f"S9_{pair}_FULL_trades.csv")
|
||||
trades.to_csv(out, index=False)
|
||||
print(f"\nTrade log saved: {out}")
|
||||
@@ -26,6 +26,7 @@ SPREAD_PIPS = {
|
||||
"EUR_GBP": 2.0, "GBP_JPY": 2.5,
|
||||
"USD_JPY": 1.5, "GBP_CAD": 2.5,
|
||||
"EUR_CAD": 2.5, "EUR_NZD": 2.5,
|
||||
"GBP_NZD": 2.5,
|
||||
}
|
||||
|
||||
# Pip value per pair
|
||||
@@ -33,6 +34,7 @@ PIP_SIZE = {
|
||||
"EUR_USD": 0.0001, "GBP_USD": 0.0001, "EUR_AUD": 0.0001,
|
||||
"GBP_AUD": 0.0001, "EUR_GBP": 0.0001, "GBP_CAD": 0.0001,
|
||||
"EUR_CAD": 0.0001, "EUR_NZD": 0.0001,
|
||||
"GBP_NZD": 0.0001,
|
||||
"USD_JPY": 0.01, "GBP_JPY": 0.01,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Standalone OANDA data fetcher — no Supabase dependency.
|
||||
Fetches H1 candles and saves to data/processed/ in the same CSV format
|
||||
as existing data files (timestamp, open, high, low, close, volume).
|
||||
|
||||
Usage:
|
||||
python src/fetch_oanda_standalone.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import pandas as pd
|
||||
|
||||
# Load OANDA key from config/.env
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
env_path = ROOT / "config" / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
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}"}
|
||||
|
||||
|
||||
def fetch_candles_chunk(instrument, granularity, from_dt, to_dt):
|
||||
"""Fetch up to 5000 candles between from_dt and to_dt."""
|
||||
params = {
|
||||
"granularity": granularity,
|
||||
"from": from_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"to": to_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
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 fetch_all_candles(instrument, granularity, start_dt, end_dt):
|
||||
"""Paginate through OANDA history in chunks of ~5000 candles."""
|
||||
gran_minutes = {
|
||||
"M1": 1, "M5": 5, "M15": 15, "M30": 30,
|
||||
"H1": 60, "H4": 240, "D": 1440, "W": 10080,
|
||||
}
|
||||
minutes = gran_minutes.get(granularity, 60)
|
||||
chunk_duration = timedelta(minutes=minutes * 4999)
|
||||
|
||||
all_candles = []
|
||||
cursor = start_dt
|
||||
chunk_num = 0
|
||||
|
||||
while cursor < end_dt:
|
||||
chunk_end = min(cursor + chunk_duration, end_dt)
|
||||
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)
|
||||
|
||||
candles = fetch_candles_chunk(instrument, granularity, cursor, chunk_end)
|
||||
print(f"{len(candles)} candles", flush=True)
|
||||
|
||||
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=minutes)
|
||||
else:
|
||||
cursor = chunk_end
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f" Total raw candles fetched: {len(all_candles)}", flush=True)
|
||||
|
||||
if not all_candles:
|
||||
return pd.DataFrame()
|
||||
|
||||
# Convert to DataFrame
|
||||
rows = []
|
||||
for c in all_candles:
|
||||
mid = c["mid"]
|
||||
rows.append({
|
||||
"timestamp": c["time"],
|
||||
"open": float(mid["o"]),
|
||||
"high": float(mid["h"]),
|
||||
"low": float(mid["l"]),
|
||||
"close": float(mid["c"]),
|
||||
"volume": c.get("volume", 0),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"])
|
||||
df = df.drop_duplicates(subset=["timestamp"], keep="last")
|
||||
df = df.sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
print(f" After dedup: {len(df)} candles", flush=True)
|
||||
print(f" Range: {df['timestamp'].iloc[0]} -> {df['timestamp'].iloc[-1]}", flush=True)
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
if not API_KEY:
|
||||
print("ERROR: No OANDA_API_KEY found in config/.env")
|
||||
sys.exit(1)
|
||||
|
||||
# Match existing data range: 2020-12-31 to 2023-08-31
|
||||
start_dt = datetime(2020, 12, 31, 0, 0, tzinfo=timezone.utc)
|
||||
end_dt = datetime(2023, 8, 31, 23, 59, tzinfo=timezone.utc)
|
||||
|
||||
pairs = ["EUR_GBP", "EUR_USD", "GBP_NZD"]
|
||||
granularity = "H1"
|
||||
out_dir = ROOT / "data" / "processed"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for pair in pairs:
|
||||
out_file = out_dir / f"{pair}_{granularity}.csv"
|
||||
if out_file.exists():
|
||||
print(f"\n{pair} already exists at {out_file}, skipping.")
|
||||
continue
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Fetching {pair} / {granularity} ({start_dt.date()} -> {end_dt.date()})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
df = fetch_all_candles(pair, granularity, start_dt, end_dt)
|
||||
if df.empty:
|
||||
print(f" No data for {pair}. Skipping.")
|
||||
continue
|
||||
|
||||
df.to_csv(out_file, index=False)
|
||||
print(f" Saved: {out_file} ({len(df)} rows)")
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -142,6 +142,17 @@ def session_vwap_bands(df: pd.DataFrame, session_start_hour: int = 8):
|
||||
}, index=df.index)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-Balance Volume (OBV)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def obv(df: pd.DataFrame) -> pd.Series:
|
||||
"""Cumulative OBV: adds volume on up-closes, subtracts on down-closes."""
|
||||
direction = np.where(df["close"] > df["close"].shift(1), 1,
|
||||
np.where(df["close"] < df["close"].shift(1), -1, 0))
|
||||
return (df["volume"] * direction).cumsum()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Swing High / Low Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -403,4 +414,7 @@ def compute_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
df["is_swing_high"] = swing_highs(df)
|
||||
df["is_swing_low"] = swing_lows(df)
|
||||
|
||||
# On-Balance Volume
|
||||
df["obv"] = obv(df)
|
||||
|
||||
return df
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Run S7, S8, S9 (Smart Money strategies) comparison.
|
||||
|
||||
Uses last 6 months of available data with 70/30 train/test split.
|
||||
Data range: ~March 2023 - August 2023 (last 6 months of processed data).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
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
|
||||
|
||||
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_USD", "EUR_AUD", "GBP_JPY"]
|
||||
|
||||
# 6 months of data, 70/30 split
|
||||
DATA_MONTHS = 6
|
||||
TRAIN_RATIO = 0.70
|
||||
|
||||
|
||||
def load_data(pair, tf, last_n_months=None):
|
||||
"""Load and compute indicators. Optionally slice to last N months."""
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||
if not os.path.exists(fp):
|
||||
print(f" WARNING: {fp} not found, skipping")
|
||||
return None
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
|
||||
if last_n_months is not None and last_n_months > 0:
|
||||
end_date = df.index[-1]
|
||||
start_date = end_date - pd.DateOffset(months=last_n_months)
|
||||
# Keep extra warmup bars (250) before the start for indicator computation
|
||||
warmup_start = start_date - pd.DateOffset(days=60)
|
||||
df = df[df.index >= warmup_start]
|
||||
|
||||
df = compute_all_indicators(df)
|
||||
|
||||
if last_n_months is not None and last_n_months > 0:
|
||||
# Now trim to the actual date range (after indicators computed with warmup)
|
||||
df = df[df.index >= start_date]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def split_data(df, train_ratio=0.70):
|
||||
"""Split data into train and test sets."""
|
||||
split_idx = int(len(df) * train_ratio)
|
||||
return df.iloc[:split_idx], df.iloc[split_idx:]
|
||||
|
||||
|
||||
def run_backtest(strat_cls, data, htf_data, pair, label=""):
|
||||
"""Run a single backtest and return report + trade log."""
|
||||
strategy = strat_cls()
|
||||
bt = Backtester(
|
||||
data=data,
|
||||
strategy=strategy,
|
||||
pair=pair,
|
||||
starting_equity=100_000.0,
|
||||
htf_data=htf_data,
|
||||
)
|
||||
report = bt.run()
|
||||
trade_log = bt.get_trade_log_df()
|
||||
return report, trade_log
|
||||
|
||||
|
||||
def run_variant(label, strat_cls, phase="FULL"):
|
||||
"""Run a strategy variant across all pairs."""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" {label} ({phase})")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
all_reports = []
|
||||
all_trades = []
|
||||
|
||||
for pair in PAIRS:
|
||||
# Load H1 as primary timeframe for S7-S9
|
||||
data = load_data(pair, "H1", last_n_months=DATA_MONTHS)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
# HTF data: use H1 itself (strategies use htf_row for trend context)
|
||||
# The backtester passes the most recent fully-closed H1 bar as htf_row
|
||||
htf_data = data.copy()
|
||||
|
||||
if phase == "TRAIN":
|
||||
data, _ = split_data(data, TRAIN_RATIO)
|
||||
htf_data, _ = split_data(htf_data, TRAIN_RATIO)
|
||||
elif phase == "TEST":
|
||||
_, data = split_data(data, TRAIN_RATIO)
|
||||
_, htf_data = split_data(htf_data, TRAIN_RATIO)
|
||||
|
||||
print(f" {pair}: H1={len(data)} bars ({data.index[0].date()} to {data.index[-1].date()})", end=" ")
|
||||
|
||||
t0 = time.time()
|
||||
report, trade_log = run_backtest(strat_cls, data, htf_data, pair, label)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
n = report.get("total_trades", 0)
|
||||
print(f"-> {n} trades ({elapsed:.0f}s)")
|
||||
|
||||
all_reports.append(report)
|
||||
if len(trade_log) > 0:
|
||||
all_trades.append(trade_log)
|
||||
|
||||
# Aggregate results
|
||||
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, "reports": all_reports}
|
||||
|
||||
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)
|
||||
|
||||
# Combined trade log for aggregate PF
|
||||
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 = 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} {phase} 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)
|
||||
dd = r.get("max_drawdown_pct", 0)
|
||||
print(f" {pair}: {n} trades, WR {wr:.1f}%, PF {pfp:.2f}, PnL {pnl:.1f}p, DD {dd:.2f}%")
|
||||
|
||||
# Save trade logs
|
||||
if len(combined) > 0:
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
csv_path = os.path.join(RESULTS_DIR, f"{label}_{phase}_trades.csv")
|
||||
combined.to_csv(csv_path, index=False)
|
||||
print(f" Trade log saved: {csv_path}")
|
||||
|
||||
return {
|
||||
"total_trades": total_trades, "win_rate": round(win_rate, 2),
|
||||
"avg_rr": round(avg_rr, 2), "expectancy": round(expectancy, 2),
|
||||
"profit_factor": round(pf, 2), "max_dd": round(max_dd, 2),
|
||||
"total_pnl_pips": round(total_pnl_pips, 1),
|
||||
"total_pnl_dollars": round(total_pnl_dollars, 2),
|
||||
"avg_win": round(avg_win, 1), "avg_loss": round(avg_loss, 1),
|
||||
"reports": all_reports,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.strategies_pkg.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||
from src.strategies_pkg.s8_order_block import S8_Order_Block
|
||||
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||
|
||||
strategies = {
|
||||
"S7": ("S7 (Liquidity Sweep Reversal)", S7_Liquidity_Sweep),
|
||||
"S8": ("S8 (Order Block Retest)", S8_Order_Block),
|
||||
"S9": ("S9 (London Session Gap)", S9_London_Session),
|
||||
}
|
||||
|
||||
# Parse CLI args
|
||||
variant = sys.argv[1].upper() if len(sys.argv) > 1 else "ALL"
|
||||
|
||||
results = {}
|
||||
|
||||
for key, (label, strat_cls) in strategies.items():
|
||||
if variant != "ALL" and variant != key:
|
||||
continue
|
||||
|
||||
print(f"\n{'#' * 70}")
|
||||
print(f"# {label}")
|
||||
print(f"{'#' * 70}")
|
||||
|
||||
# Full 6-month run
|
||||
full = run_variant(key, strat_cls, phase="FULL")
|
||||
|
||||
# 70/30 split: train
|
||||
train = run_variant(key, strat_cls, phase="TRAIN")
|
||||
|
||||
# 70/30 split: test (OOS)
|
||||
test = run_variant(key, strat_cls, phase="TEST")
|
||||
|
||||
results[key] = {"full": full, "train": train, "test": test}
|
||||
|
||||
# Summary comparison
|
||||
if len(results) > 1:
|
||||
print(f"\n{'=' * 80}")
|
||||
print("STRATEGY COMPARISON — FULL 6 MONTHS")
|
||||
print(f"{'=' * 80}")
|
||||
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]["full"].get(metric, 0)
|
||||
if isinstance(v, float):
|
||||
print(f"{v:>18.2f}", end="")
|
||||
else:
|
||||
print(f"{v:>18}", end="")
|
||||
print()
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print("TRAIN vs TEST (70/30 Split)")
|
||||
print(f"{'=' * 80}")
|
||||
for k in results:
|
||||
train_r = results[k]["train"]
|
||||
test_r = results[k]["test"]
|
||||
print(f"\n {k}:")
|
||||
print(f" TRAIN: {train_r.get('total_trades',0)} trades, "
|
||||
f"WR {train_r.get('win_rate',0):.1f}%, "
|
||||
f"PF {train_r.get('profit_factor',0):.2f}, "
|
||||
f"PnL {train_r.get('total_pnl_pips',0):.1f}p")
|
||||
print(f" TEST: {test_r.get('total_trades',0)} trades, "
|
||||
f"WR {test_r.get('win_rate',0):.1f}%, "
|
||||
f"PF {test_r.get('profit_factor',0):.2f}, "
|
||||
f"PnL {test_r.get('total_pnl_pips',0):.1f}p")
|
||||
if train_r.get('profit_factor', 0) > 0 and test_r.get('profit_factor', 0) > 0:
|
||||
gen_ratio = test_r['profit_factor'] / train_r['profit_factor']
|
||||
print(f" Generalization Ratio (PF): {gen_ratio:.2f}")
|
||||
|
||||
# Save summary
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
summary = {}
|
||||
for k, v in results.items():
|
||||
summary[k] = {
|
||||
phase: {mk: mv for mk, mv in data.items() if mk != "reports"}
|
||||
for phase, data in v.items()
|
||||
}
|
||||
with open(os.path.join(RESULTS_DIR, "s789_comparison.json"), "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
print(f"\nSummary saved: {os.path.join(RESULTS_DIR, 's789_comparison.json')}")
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
S7 Liquidity Sweep — Expanded testing.
|
||||
|
||||
1. GBP_JPY: Full dataset (2021-2023) with 70/30 split
|
||||
2. Other 5 pairs: Last 6 months with 70/30 split
|
||||
- GBP_USD, EUR_AUD, GBP_AUD, EUR_USD, GBP_NZD
|
||||
|
||||
Uses the tight SL variant (1.0 ATR) which proved best on GBP_JPY 6-month.
|
||||
"""
|
||||
import os, sys, io, json, time
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
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.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||
|
||||
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")
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
|
||||
TRAIN_RATIO = 0.70
|
||||
|
||||
|
||||
def load_data(pair, last_n_months=None):
|
||||
"""Load H1 data, optionally slice to last N months (with warmup)."""
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_H1.csv")
|
||||
if not os.path.exists(fp):
|
||||
print(f" WARNING: {fp} not found, skipping")
|
||||
return None
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
|
||||
if last_n_months is not None and last_n_months > 0:
|
||||
end_date = df.index[-1]
|
||||
start_date = end_date - pd.DateOffset(months=last_n_months)
|
||||
warmup_start = start_date - pd.DateOffset(days=60)
|
||||
df = df[df.index >= warmup_start]
|
||||
|
||||
df = compute_all_indicators(df)
|
||||
|
||||
if last_n_months is not None and last_n_months > 0:
|
||||
df = df[df.index >= start_date]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def split_data(df):
|
||||
split_idx = int(len(df) * TRAIN_RATIO)
|
||||
return df.iloc[:split_idx], df.iloc[split_idx:]
|
||||
|
||||
|
||||
def run_test(pair, data, htf_data, phase="FULL"):
|
||||
if phase == "TRAIN":
|
||||
data, _ = split_data(data)
|
||||
htf_data, _ = split_data(htf_data)
|
||||
elif phase == "TEST":
|
||||
_, data = split_data(data)
|
||||
_, htf_data = split_data(htf_data)
|
||||
|
||||
strategy = S7_Liquidity_Sweep()
|
||||
bt = Backtester(data=data, strategy=strategy, pair=pair,
|
||||
starting_equity=100_000.0, htf_data=htf_data)
|
||||
report = bt.run()
|
||||
trade_log = bt.get_trade_log_df()
|
||||
return report, trade_log
|
||||
|
||||
|
||||
def extract_metrics(report):
|
||||
n = report.get("total_trades", 0)
|
||||
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)
|
||||
aw = report.get("avg_win_pips", 0)
|
||||
al = report.get("avg_loss_pips", 0)
|
||||
rr = aw / al if al > 0 else 0
|
||||
exp = pnl / n if n > 0 else 0
|
||||
return {
|
||||
"trades": n, "wr": round(wr, 1), "pf": round(pf, 2),
|
||||
"pnl_pips": round(pnl, 1), "pnl_usd": round(pnl_d, 2),
|
||||
"dd": round(dd, 2), "avg_win": round(aw, 1),
|
||||
"avg_loss": round(al, 1), "rr": round(rr, 2),
|
||||
"expectancy": round(exp, 2),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
all_results = {}
|
||||
|
||||
# ---- TEST 1: GBP_JPY Full Dataset ----
|
||||
pair = "GBP_JPY"
|
||||
print(f"\n{'#' * 70}")
|
||||
print(f"# S7 Tight — {pair} FULL DATASET (2021-2023)")
|
||||
print(f"{'#' * 70}")
|
||||
|
||||
data = load_data(pair, last_n_months=None)
|
||||
htf = data.copy()
|
||||
print(f" Data: {len(data)} H1 bars, {data.index[0].date()} to {data.index[-1].date()}")
|
||||
|
||||
pair_results = {}
|
||||
for phase in ["FULL", "TRAIN", "TEST"]:
|
||||
t0 = time.time()
|
||||
report, trade_log = run_test(pair, data, htf, phase)
|
||||
elapsed = time.time() - t0
|
||||
m = extract_metrics(report)
|
||||
pair_results[phase] = m
|
||||
|
||||
print(f" {phase:>5}: {m['trades']:>3} trades | WR {m['wr']:>5.1f}% | "
|
||||
f"PF {m['pf']:>5.2f} | PnL {m['pnl_pips']:>+8.1f}p | "
|
||||
f"DD {m['dd']:>6.2f}% | AvgW {m['avg_win']:>5.1f} / "
|
||||
f"AvgL {m['avg_loss']:>5.1f} | RR {m['rr']:>4.2f} | "
|
||||
f"Exp {m['expectancy']:>+6.2f}p ({elapsed:.0f}s)")
|
||||
|
||||
if len(trade_log) > 0:
|
||||
trade_log.to_csv(os.path.join(RESULTS_DIR, f"S7T_{pair}_{phase}_trades.csv"), index=False)
|
||||
|
||||
train_pf = pair_results.get("TRAIN", {}).get("pf", 0)
|
||||
test_pf = pair_results.get("TEST", {}).get("pf", 0)
|
||||
if train_pf > 0 and test_pf > 0:
|
||||
print(f" Gen Ratio: {test_pf / train_pf:.2f}")
|
||||
|
||||
all_results[pair] = {"mode": "FULL_DATASET", **pair_results}
|
||||
|
||||
# ---- TEST 2: Other 5 pairs, 6 months ----
|
||||
other_pairs = ["GBP_USD", "EUR_AUD", "GBP_AUD", "EUR_USD", "GBP_NZD"]
|
||||
|
||||
for pair in other_pairs:
|
||||
print(f"\n{'#' * 70}")
|
||||
print(f"# S7 Tight — {pair} (6 months)")
|
||||
print(f"{'#' * 70}")
|
||||
|
||||
data = load_data(pair, last_n_months=6)
|
||||
if data is None:
|
||||
continue
|
||||
htf = data.copy()
|
||||
print(f" Data: {len(data)} H1 bars, {data.index[0].date()} to {data.index[-1].date()}")
|
||||
|
||||
pair_results = {}
|
||||
for phase in ["FULL", "TRAIN", "TEST"]:
|
||||
t0 = time.time()
|
||||
report, trade_log = run_test(pair, data, htf, phase)
|
||||
elapsed = time.time() - t0
|
||||
m = extract_metrics(report)
|
||||
pair_results[phase] = m
|
||||
|
||||
print(f" {phase:>5}: {m['trades']:>3} trades | WR {m['wr']:>5.1f}% | "
|
||||
f"PF {m['pf']:>5.2f} | PnL {m['pnl_pips']:>+8.1f}p | "
|
||||
f"DD {m['dd']:>6.2f}% | AvgW {m['avg_win']:>5.1f} / "
|
||||
f"AvgL {m['avg_loss']:>5.1f} | RR {m['rr']:>4.2f} | "
|
||||
f"Exp {m['expectancy']:>+6.2f}p ({elapsed:.0f}s)")
|
||||
|
||||
if len(trade_log) > 0:
|
||||
trade_log.to_csv(os.path.join(RESULTS_DIR, f"S7T_{pair}_{phase}_trades.csv"), index=False)
|
||||
|
||||
train_pf = pair_results.get("TRAIN", {}).get("pf", 0)
|
||||
test_pf = pair_results.get("TEST", {}).get("pf", 0)
|
||||
if train_pf > 0 and test_pf > 0:
|
||||
print(f" Gen Ratio: {test_pf / train_pf:.2f}")
|
||||
|
||||
all_results[pair] = {"mode": "6_MONTHS", **pair_results}
|
||||
|
||||
# ---- SUMMARY TABLE ----
|
||||
print(f"\n{'=' * 90}")
|
||||
print("S7 TIGHT (1.0 ATR SL) — ALL PAIRS SUMMARY")
|
||||
print(f"{'=' * 90}")
|
||||
print(f"{'Pair':<10} {'Mode':<12} {'Phase':<6} {'Trades':>6} {'WR%':>6} "
|
||||
f"{'PF':>6} {'PnL(p)':>9} {'RR':>5} {'Exp(p)':>7} {'DD%':>7}")
|
||||
print("-" * 90)
|
||||
for pair, data in all_results.items():
|
||||
mode = data["mode"]
|
||||
for phase in ["FULL", "TEST"]:
|
||||
m = data.get(phase, {})
|
||||
if not m:
|
||||
continue
|
||||
print(f"{pair:<10} {mode:<12} {phase:<6} {m['trades']:>6} "
|
||||
f"{m['wr']:>5.1f}% {m['pf']:>5.2f} {m['pnl_pips']:>+8.1f}p "
|
||||
f"{m['rr']:>4.2f} {m['expectancy']:>+6.2f}p {m['dd']:>6.2f}%")
|
||||
print()
|
||||
|
||||
# Save
|
||||
out_path = os.path.join(RESULTS_DIR, "s7_expanded_results.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"Results saved: {out_path}")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""S7 Tight — Full dataset validation for GBP_USD and EUR_USD."""
|
||||
import os, sys, io, time
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
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.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||
|
||||
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")
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
|
||||
TRAIN_RATIO = 0.70
|
||||
|
||||
for pair in ["GBP_USD", "EUR_USD"]:
|
||||
print(f"\n{'#' * 70}")
|
||||
print(f"# S7 Tight — {pair} FULL DATASET")
|
||||
print(f"{'#' * 70}")
|
||||
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_H1.csv")
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
df = compute_all_indicators(df)
|
||||
htf = df.copy()
|
||||
print(f" Data: {len(df)} H1 bars, {df.index[0].date()} to {df.index[-1].date()}")
|
||||
|
||||
split_idx = int(len(df) * TRAIN_RATIO)
|
||||
splits = {
|
||||
"FULL": (df, htf),
|
||||
"TRAIN": (df.iloc[:split_idx], htf.iloc[:split_idx]),
|
||||
"TEST": (df.iloc[split_idx:], htf.iloc[split_idx:]),
|
||||
}
|
||||
|
||||
for phase, (d, h) in splits.items():
|
||||
t0 = time.time()
|
||||
bt = Backtester(data=d, strategy=S7_Liquidity_Sweep(), pair=pair,
|
||||
starting_equity=100_000.0, htf_data=h)
|
||||
r = bt.run()
|
||||
tl = bt.get_trade_log_df()
|
||||
elapsed = time.time() - t0
|
||||
|
||||
n = r.get("total_trades", 0)
|
||||
wr = r.get("win_rate_pct", 0)
|
||||
pf = r.get("profit_factor", 0)
|
||||
pnl = r.get("total_pnl_pips", 0)
|
||||
pnl_d = r.get("total_pnl_dollars", 0)
|
||||
dd = r.get("max_drawdown_pct", 0)
|
||||
aw = r.get("avg_win_pips", 0)
|
||||
al = r.get("avg_loss_pips", 0)
|
||||
rr = aw / al if al > 0 else 0
|
||||
exp = pnl / n if n > 0 else 0
|
||||
|
||||
print(f" {phase:>5}: {n:>3} trades | WR {wr:>5.1f}% | PF {pf:>5.2f} | "
|
||||
f"PnL {pnl:>+8.1f}p (${pnl_d:>+10,.2f}) | DD {dd:>6.2f}% | "
|
||||
f"AvgW {aw:>5.1f} / AvgL {al:>5.1f} | RR {rr:>4.2f} | "
|
||||
f"Exp {exp:>+6.2f}p ({elapsed:.0f}s)")
|
||||
|
||||
if len(tl) > 0:
|
||||
tl.to_csv(os.path.join(RESULTS_DIR, f"S7T_{pair}_FULL_{phase}_trades.csv"), index=False)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
S9 Full Dataset Test — Additional London-centric pairs.
|
||||
Tests EUR_GBP, EUR_USD, GBP_NZD on the full 2021-2023 dataset.
|
||||
(GBP_AUD and GBP_USD already tested previously.)
|
||||
|
||||
Uses FULL dataset with 70/30 train/test split.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import io
|
||||
|
||||
# Fix Windows encoding
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
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")
|
||||
|
||||
# New pairs to test (London-centric: GBP or EUR)
|
||||
PAIRS = ["EUR_GBP", "EUR_USD", "GBP_NZD"]
|
||||
|
||||
TRAIN_RATIO = 0.70
|
||||
|
||||
|
||||
def load_data(pair, tf):
|
||||
"""Load full dataset and compute indicators."""
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||
if not os.path.exists(fp):
|
||||
print(f" WARNING: {fp} not found, skipping")
|
||||
return None
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
df = compute_all_indicators(df)
|
||||
return df
|
||||
|
||||
|
||||
def split_data(df, train_ratio=0.70):
|
||||
split_idx = int(len(df) * train_ratio)
|
||||
return df.iloc[:split_idx], df.iloc[split_idx:]
|
||||
|
||||
|
||||
def run_pair(strat_cls, pair, data, htf_data, label, phase):
|
||||
"""Run backtest on a single pair/phase."""
|
||||
if phase == "TRAIN":
|
||||
data, _ = split_data(data, TRAIN_RATIO)
|
||||
htf_data, _ = split_data(htf_data, TRAIN_RATIO)
|
||||
elif phase == "TEST":
|
||||
_, data = split_data(data, TRAIN_RATIO)
|
||||
_, htf_data = split_data(htf_data, TRAIN_RATIO)
|
||||
|
||||
strategy = strat_cls()
|
||||
bt = Backtester(
|
||||
data=data,
|
||||
strategy=strategy,
|
||||
pair=pair,
|
||||
starting_equity=100_000.0,
|
||||
htf_data=htf_data,
|
||||
)
|
||||
report = bt.run()
|
||||
trade_log = bt.get_trade_log_df()
|
||||
return report, trade_log
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||
|
||||
results = {}
|
||||
|
||||
for pair in PAIRS:
|
||||
print(f"\n{'#' * 70}")
|
||||
print(f"# S9 London Session — {pair} (Full Dataset)")
|
||||
print(f"{'#' * 70}")
|
||||
|
||||
data = load_data(pair, "H1")
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
htf_data = data.copy()
|
||||
print(f" Data: {len(data)} H1 bars, {data.index[0].date()} to {data.index[-1].date()}")
|
||||
|
||||
pair_results = {}
|
||||
for phase in ["FULL", "TRAIN", "TEST"]:
|
||||
t0 = time.time()
|
||||
report, trade_log = run_pair(S9_London_Session, pair, data, htf_data, "S9", phase)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
n = report.get("total_trades", 0)
|
||||
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)
|
||||
avg_w = report.get("avg_win_pips", 0)
|
||||
avg_l = report.get("avg_loss_pips", 0)
|
||||
avg_rr = avg_w / avg_l if avg_l > 0 else 0
|
||||
|
||||
print(f"\n {phase}: {n} trades, WR {wr:.1f}%, PF {pf:.2f}, "
|
||||
f"PnL {pnl:.1f}p (${pnl_d:,.2f}), DD {dd:.2f}%, "
|
||||
f"AvgW {avg_w:.1f}p / AvgL {avg_l:.1f}p, RR {avg_rr:.2f} ({elapsed:.0f}s)")
|
||||
|
||||
pair_results[phase] = {
|
||||
"total_trades": n, "win_rate": round(wr, 2),
|
||||
"avg_rr": round(avg_rr, 2),
|
||||
"expectancy": round(pnl / n, 2) if n > 0 else 0,
|
||||
"profit_factor": round(pf, 2),
|
||||
"max_dd": round(dd, 2),
|
||||
"total_pnl_pips": round(pnl, 1),
|
||||
"total_pnl_dollars": round(pnl_d, 2),
|
||||
"avg_win": round(avg_w, 1), "avg_loss": round(avg_l, 1),
|
||||
}
|
||||
|
||||
# Save trade log
|
||||
if len(trade_log) > 0:
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
csv_path = os.path.join(RESULTS_DIR, f"S9_{pair}_{phase}_trades.csv")
|
||||
trade_log.to_csv(csv_path, index=False)
|
||||
|
||||
# Generalization check
|
||||
train_pf = pair_results.get("TRAIN", {}).get("profit_factor", 0)
|
||||
test_pf = pair_results.get("TEST", {}).get("profit_factor", 0)
|
||||
if train_pf > 0 and test_pf > 0:
|
||||
gen = test_pf / train_pf
|
||||
print(f"\n Generalization Ratio: {gen:.2f} (OOS PF / IS PF)")
|
||||
|
||||
results[pair] = pair_results
|
||||
|
||||
# Summary table
|
||||
print(f"\n{'=' * 80}")
|
||||
print("S9 ADDITIONAL PAIRS — SUMMARY")
|
||||
print(f"{'=' * 80}")
|
||||
print(f"{'Pair':<12} {'Phase':<8} {'Trades':>7} {'WR%':>7} {'PF':>7} {'PnL(p)':>10} {'DD%':>7} {'AvgW':>7} {'AvgL':>7} {'RR':>6}")
|
||||
print("-" * 80)
|
||||
for pair, phases in results.items():
|
||||
for phase in ["FULL", "TRAIN", "TEST"]:
|
||||
d = phases.get(phase, {})
|
||||
print(f"{pair:<12} {phase:<8} {d.get('total_trades',0):>7} "
|
||||
f"{d.get('win_rate',0):>6.1f}% {d.get('profit_factor',0):>6.2f} "
|
||||
f"{d.get('total_pnl_pips',0):>9.1f}p {d.get('max_dd',0):>6.2f}% "
|
||||
f"{d.get('avg_win',0):>6.1f} {d.get('avg_loss',0):>6.1f} "
|
||||
f"{d.get('avg_rr',0):>5.2f}")
|
||||
print()
|
||||
|
||||
# Save results
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
out_path = os.path.join(RESULTS_DIR, "s9_additional_pairs.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Results saved: {out_path}")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""S9 EUR_USD: Baseline vs Light Filter (RSI skip + TP1 1.5x only)."""
|
||||
import os, sys, io, time
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
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.s9_london_session import S9_London_Session
|
||||
|
||||
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")
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
|
||||
pair = "EUR_USD"
|
||||
TRAIN_RATIO = 0.70
|
||||
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_H1.csv")
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
df = compute_all_indicators(df)
|
||||
htf = df.copy()
|
||||
print(f"Data: {len(df)} H1 bars, {df.index[0].date()} to {df.index[-1].date()}")
|
||||
|
||||
split_idx = int(len(df) * TRAIN_RATIO)
|
||||
train_d, test_d = df.iloc[:split_idx], df.iloc[split_idx:]
|
||||
train_h, test_h = htf.iloc[:split_idx], htf.iloc[split_idx:]
|
||||
|
||||
variants = {
|
||||
"BASELINE": S9_London_Session(),
|
||||
"LIGHT_FILTER": S9_London_Session(pair=pair, filtered=True),
|
||||
}
|
||||
|
||||
print(f"\n{'Variant':<16} {'Phase':<6} {'Trades':>6} {'WR%':>6} {'PF':>6} "
|
||||
f"{'PnL(p)':>9} {'RR':>5} {'Exp(p)':>7} {'DD%':>7}")
|
||||
print("-" * 80)
|
||||
|
||||
for label, strat in variants.items():
|
||||
for phase, d, h in [("FULL", df, htf), ("TRAIN", train_d, train_h), ("TEST", test_d, test_h)]:
|
||||
s = S9_London_Session(pair=pair, filtered=(label != "BASELINE"))
|
||||
bt = Backtester(data=d, strategy=s, pair=pair, starting_equity=100_000.0, htf_data=h)
|
||||
r = bt.run()
|
||||
tl = bt.get_trade_log_df()
|
||||
|
||||
n = r.get("total_trades", 0)
|
||||
wr = r.get("win_rate_pct", 0)
|
||||
pf = r.get("profit_factor", 0)
|
||||
pnl = r.get("total_pnl_pips", 0)
|
||||
dd = r.get("max_drawdown_pct", 0)
|
||||
aw = r.get("avg_win_pips", 0)
|
||||
al = r.get("avg_loss_pips", 0)
|
||||
rr = aw / al if al > 0 else 0
|
||||
exp = pnl / n if n > 0 else 0
|
||||
|
||||
print(f"{label:<16} {phase:<6} {n:>6} {wr:>5.1f}% {pf:>5.2f} "
|
||||
f"{pnl:>+8.1f}p {rr:>4.2f} {exp:>+6.2f}p {dd:>6.2f}%")
|
||||
|
||||
if label == "LIGHT_FILTER" and len(tl) > 0:
|
||||
tl.to_csv(os.path.join(RESULTS_DIR, f"S9FL_{pair}_{phase}_trades.csv"), index=False)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
S9 Filtered vs Baseline comparison on EUR_USD and GBP_AUD.
|
||||
|
||||
Filters applied:
|
||||
EUR_USD: ADX > 30, skip RSI 40-60, entry from Hour 8, TP1 = 1.5x range
|
||||
GBP_AUD: ADX > 25, skip Friday, EMA50 distance > 40 pips
|
||||
"""
|
||||
import os, sys, io, json, time
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
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.s9_london_session import S9_London_Session
|
||||
|
||||
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")
|
||||
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||
|
||||
PAIRS = ["EUR_USD", "GBP_AUD"]
|
||||
TRAIN_RATIO = 0.70
|
||||
|
||||
|
||||
def load_data(pair):
|
||||
fp = os.path.join(PROCESSED_DIR, f"{pair}_H1.csv")
|
||||
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||
df.index.name = "timestamp"
|
||||
df = compute_all_indicators(df)
|
||||
return df
|
||||
|
||||
|
||||
def split_data(df):
|
||||
split_idx = int(len(df) * TRAIN_RATIO)
|
||||
return df.iloc[:split_idx], df.iloc[split_idx:]
|
||||
|
||||
|
||||
def run_test(strategy, pair, data, htf_data, phase="FULL"):
|
||||
if phase == "TRAIN":
|
||||
data, _ = split_data(data)
|
||||
htf_data, _ = split_data(htf_data)
|
||||
elif phase == "TEST":
|
||||
_, data = split_data(data)
|
||||
_, htf_data = split_data(htf_data)
|
||||
|
||||
bt = Backtester(data=data, strategy=strategy, pair=pair,
|
||||
starting_equity=100_000.0, htf_data=htf_data)
|
||||
report = bt.run()
|
||||
trade_log = bt.get_trade_log_df()
|
||||
return report, trade_log
|
||||
|
||||
|
||||
def extract_metrics(report, trade_log):
|
||||
n = report.get("total_trades", 0)
|
||||
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)
|
||||
avg_w = report.get("avg_win_pips", 0)
|
||||
avg_l = report.get("avg_loss_pips", 0)
|
||||
rr = avg_w / avg_l if avg_l > 0 else 0
|
||||
exp = pnl / n if n > 0 else 0
|
||||
return {
|
||||
"trades": n, "wr": round(wr, 1), "pf": round(pf, 2),
|
||||
"pnl_pips": round(pnl, 1), "pnl_usd": round(pnl_d, 2),
|
||||
"dd": round(dd, 2), "avg_win": round(avg_w, 1),
|
||||
"avg_loss": round(avg_l, 1), "rr": round(rr, 2),
|
||||
"expectancy": round(exp, 2),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
all_results = {}
|
||||
|
||||
for pair in PAIRS:
|
||||
print(f"\n{'#' * 70}")
|
||||
print(f"# {pair}")
|
||||
print(f"{'#' * 70}")
|
||||
|
||||
data = load_data(pair)
|
||||
htf_data = data.copy()
|
||||
print(f" Data: {len(data)} H1 bars, {data.index[0].date()} to {data.index[-1].date()}")
|
||||
|
||||
pair_results = {}
|
||||
|
||||
for variant, filtered in [("BASELINE", False), ("FILTERED", True)]:
|
||||
print(f"\n --- {variant} ---")
|
||||
variant_results = {}
|
||||
|
||||
for phase in ["FULL", "TRAIN", "TEST"]:
|
||||
strat = S9_London_Session(pair=pair, filtered=filtered)
|
||||
t0 = time.time()
|
||||
report, trade_log = run_test(strat, pair, data, htf_data, phase)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
m = extract_metrics(report, trade_log)
|
||||
variant_results[phase] = m
|
||||
|
||||
print(f" {phase:>5}: {m['trades']:>3} trades | WR {m['wr']:>5.1f}% | "
|
||||
f"PF {m['pf']:>5.2f} | PnL {m['pnl_pips']:>+8.1f}p | "
|
||||
f"DD {m['dd']:>6.2f}% | AvgW {m['avg_win']:>5.1f} / "
|
||||
f"AvgL {m['avg_loss']:>5.1f} | RR {m['rr']:>4.2f} | "
|
||||
f"Exp {m['expectancy']:>+6.2f}p ({elapsed:.0f}s)")
|
||||
|
||||
# Save trade log for filtered version
|
||||
if filtered and len(trade_log) > 0:
|
||||
csv_path = os.path.join(RESULTS_DIR, f"S9F_{pair}_{phase}_trades.csv")
|
||||
trade_log.to_csv(csv_path, index=False)
|
||||
|
||||
# Generalization ratio
|
||||
train_pf = variant_results.get("TRAIN", {}).get("pf", 0)
|
||||
test_pf = variant_results.get("TEST", {}).get("pf", 0)
|
||||
if train_pf > 0 and test_pf > 0:
|
||||
gen = test_pf / train_pf
|
||||
print(f" Gen Ratio: {gen:.2f}")
|
||||
|
||||
pair_results[variant] = variant_results
|
||||
|
||||
# Delta summary
|
||||
base_full = pair_results["BASELINE"]["FULL"]
|
||||
filt_full = pair_results["FILTERED"]["FULL"]
|
||||
base_test = pair_results["BASELINE"]["TEST"]
|
||||
filt_test = pair_results["FILTERED"]["TEST"]
|
||||
|
||||
print(f"\n IMPROVEMENT (FULL):")
|
||||
print(f" Trades: {base_full['trades']} -> {filt_full['trades']} "
|
||||
f"({filt_full['trades'] - base_full['trades']:+d})")
|
||||
print(f" WR: {base_full['wr']}% -> {filt_full['wr']}% "
|
||||
f"({filt_full['wr'] - base_full['wr']:+.1f}%)")
|
||||
print(f" PF: {base_full['pf']} -> {filt_full['pf']} "
|
||||
f"({filt_full['pf'] - base_full['pf']:+.2f})")
|
||||
print(f" PnL: {base_full['pnl_pips']:+.1f}p -> {filt_full['pnl_pips']:+.1f}p "
|
||||
f"({filt_full['pnl_pips'] - base_full['pnl_pips']:+.1f}p)")
|
||||
print(f" RR: {base_full['rr']} -> {filt_full['rr']} "
|
||||
f"({filt_full['rr'] - base_full['rr']:+.2f})")
|
||||
|
||||
print(f"\n IMPROVEMENT (OOS TEST):")
|
||||
print(f" Trades: {base_test['trades']} -> {filt_test['trades']}")
|
||||
print(f" WR: {base_test['wr']}% -> {filt_test['wr']}%")
|
||||
print(f" PF: {base_test['pf']} -> {filt_test['pf']}")
|
||||
print(f" PnL: {base_test['pnl_pips']:+.1f}p -> {filt_test['pnl_pips']:+.1f}p")
|
||||
|
||||
all_results[pair] = pair_results
|
||||
|
||||
# Final comparison table
|
||||
print(f"\n{'=' * 80}")
|
||||
print("FINAL COMPARISON: BASELINE vs FILTERED")
|
||||
print(f"{'=' * 80}")
|
||||
print(f"{'Pair':<10} {'Variant':<10} {'Phase':<6} {'Trades':>7} {'WR%':>6} "
|
||||
f"{'PF':>6} {'PnL(p)':>9} {'RR':>5} {'Exp(p)':>7}")
|
||||
print("-" * 80)
|
||||
for pair in PAIRS:
|
||||
for variant in ["BASELINE", "FILTERED"]:
|
||||
for phase in ["FULL", "TEST"]:
|
||||
m = all_results[pair][variant][phase]
|
||||
print(f"{pair:<10} {variant:<10} {phase:<6} {m['trades']:>7} "
|
||||
f"{m['wr']:>5.1f}% {m['pf']:>5.2f} {m['pnl_pips']:>+8.1f}p "
|
||||
f"{m['rr']:>4.2f} {m['expectancy']:>+6.2f}p")
|
||||
if variant == "BASELINE":
|
||||
print()
|
||||
print("-" * 80)
|
||||
|
||||
# Save
|
||||
out_path = os.path.join(RESULTS_DIR, "s9_filtered_comparison.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nResults saved: {out_path}")
|
||||
@@ -5,6 +5,9 @@ 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
|
||||
from .s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||
from .s8_order_block import S8_Order_Block
|
||||
from .s9_london_session import S9_London_Session
|
||||
|
||||
STRATEGIES = {
|
||||
1: S1_MA_Breakout,
|
||||
@@ -13,6 +16,9 @@ STRATEGIES = {
|
||||
4: S4_EMA_Ribbon,
|
||||
5: S5_Momentum_Exhaustion,
|
||||
6: S6_EMA_Bounce,
|
||||
7: S7_Liquidity_Sweep,
|
||||
8: S8_Order_Block,
|
||||
9: S9_London_Session,
|
||||
}
|
||||
|
||||
# Which pairs each strategy trades
|
||||
@@ -25,6 +31,9 @@ STRATEGY_PAIRS = {
|
||||
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
|
||||
7: ["GBP_USD", "GBP_JPY", "EUR_AUD"],
|
||||
8: ["GBP_USD", "EUR_AUD", "GBP_JPY"],
|
||||
9: ["GBP_USD", "EUR_AUD", "GBP_JPY"],
|
||||
}
|
||||
|
||||
# Primary and filter timeframes
|
||||
@@ -35,4 +44,7 @@ STRATEGY_TIMEFRAMES = {
|
||||
4: {"primary": "M15", "filter": "H1"},
|
||||
5: {"primary": "M15", "filter": "H1"},
|
||||
6: {"primary": "M15", "filter": "H1"},
|
||||
7: {"primary": "H1", "filter": None}, # H1 primary, internal HTF via htf_data
|
||||
8: {"primary": "H1", "filter": None}, # H1 primary, internal HTF via htf_data
|
||||
9: {"primary": "H1", "filter": None}, # H1 primary, internal HTF via htf_data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Strategy S7: Liquidity Sweep Reversal.
|
||||
|
||||
Concept: Price sweeps past a significant swing high/low (triggering clustered
|
||||
stop-loss orders), then reverses. The most empirically-supported SMC concept
|
||||
(Osler 2005, NY Fed).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Identify significant swing high/low (5-bar fractal) within last 100 bars
|
||||
2. Price penetrates the swing level by 0.7-1.0 ATR (the sweep)
|
||||
3. Price closes back inside the previous range (reversal candle)
|
||||
4. OBV divergence: price makes new extreme but OBV doesn't confirm
|
||||
(institutional absorption signal)
|
||||
5. HTF (H1) trend alignment: only take sweeps in the direction of
|
||||
the higher-timeframe trend (200 EMA bias)
|
||||
6. Session filter: London/NY hours (08:00-17:00 UTC)
|
||||
7. RSI < 35 (for longs) or > 65 (for shorts) as a filter
|
||||
|
||||
Exit:
|
||||
- SL: 1.5 ATR beyond the sweep extreme
|
||||
- TP1: 1.5 ATR from entry (close 50%)
|
||||
- TP2: 3.0 ATR from entry (close 50%)
|
||||
- Max hold: 40 bars (H1 = ~40 hours)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S7_Liquidity_Sweep(BaseStrategy):
|
||||
strategy_id = 7
|
||||
name = "S7_Liquidity_Sweep"
|
||||
|
||||
# Tunable parameters
|
||||
SWING_LOOKBACK = 5 # N-bar fractal for swing detection
|
||||
SWING_HISTORY = 100 # How far back to search for swing levels
|
||||
SWEEP_MIN_ATR = 0.7 # Minimum penetration for a sweep
|
||||
SWEEP_MAX_ATR = 1.5 # Above this = genuine breakout, not a sweep
|
||||
SL_ATR_MULT = 1.0 # SL beyond sweep extreme (tightened from 1.5)
|
||||
TP1_ATR_MULT = 1.5 # First take-profit
|
||||
TP2_ATR_MULT = 3.0 # Second take-profit
|
||||
OBV_LOOKBACK = 20 # Lookback for OBV divergence detection
|
||||
MAX_BARS = 40
|
||||
|
||||
def _find_swing_levels(self, data, idx):
|
||||
"""Find significant swing highs and lows within lookback window."""
|
||||
start = max(0, idx - self.SWING_HISTORY)
|
||||
# Exclude the very recent bars (last 3) to avoid detecting current price action
|
||||
end = idx - 2
|
||||
if end - start < 20:
|
||||
return [], []
|
||||
|
||||
swing_highs = []
|
||||
swing_lows = []
|
||||
lb = self.SWING_LOOKBACK
|
||||
|
||||
for i in range(start + lb, end - lb + 1):
|
||||
# Swing high: highest high in [i-lb, i+lb]
|
||||
window_highs = data["high"].iloc[i - lb:i + lb + 1]
|
||||
if data["high"].iloc[i] == window_highs.max():
|
||||
swing_highs.append((i, data["high"].iloc[i]))
|
||||
|
||||
# Swing low: lowest low in [i-lb, i+lb]
|
||||
window_lows = data["low"].iloc[i - lb:i + lb + 1]
|
||||
if data["low"].iloc[i] == window_lows.min():
|
||||
swing_lows.append((i, data["low"].iloc[i]))
|
||||
|
||||
return swing_highs, swing_lows
|
||||
|
||||
def _find_equal_levels(self, levels, atr_val):
|
||||
"""Find clusters of equal highs/lows (within 0.1 ATR) — highest probability targets."""
|
||||
if len(levels) < 2:
|
||||
return levels
|
||||
tolerance = 0.1 * atr_val
|
||||
clustered = []
|
||||
used = set()
|
||||
for i, (idx_i, price_i) in enumerate(levels):
|
||||
if i in used:
|
||||
continue
|
||||
cluster = [(idx_i, price_i)]
|
||||
used.add(i)
|
||||
for j, (idx_j, price_j) in enumerate(levels):
|
||||
if j in used:
|
||||
continue
|
||||
if abs(price_j - price_i) <= tolerance:
|
||||
cluster.append((idx_j, price_j))
|
||||
used.add(j)
|
||||
if len(cluster) >= 2:
|
||||
# Use the average price for the cluster, latest index
|
||||
avg_price = np.mean([p for _, p in cluster])
|
||||
latest_idx = max(idx for idx, _ in cluster)
|
||||
clustered.append((latest_idx, avg_price))
|
||||
else:
|
||||
clustered.append((idx_i, price_i))
|
||||
return clustered
|
||||
|
||||
def _check_obv_divergence(self, data, idx, direction):
|
||||
"""Check for OBV divergence (institutional absorption signal)."""
|
||||
lb = self.OBV_LOOKBACK
|
||||
if idx < lb:
|
||||
return False
|
||||
|
||||
window = data.iloc[idx - lb:idx + 1]
|
||||
obv_vals = window.get("obv")
|
||||
if obv_vals is None:
|
||||
return False
|
||||
|
||||
if direction == "LONG":
|
||||
# Bullish OBV divergence: price makes lower low but OBV makes higher low
|
||||
price_lows = window["low"]
|
||||
recent_low_pos = price_lows.values.argmin()
|
||||
if recent_low_pos < lb - 5:
|
||||
return False # Low isn't recent enough
|
||||
# Find previous low in first half of window
|
||||
first_half = price_lows.iloc[:lb // 2]
|
||||
if len(first_half) < 3:
|
||||
return False
|
||||
prev_low_pos = first_half.values.argmin()
|
||||
if (price_lows.iloc[recent_low_pos] < first_half.iloc[prev_low_pos] and
|
||||
obv_vals.iloc[recent_low_pos] > obv_vals.iloc[prev_low_pos]):
|
||||
return True
|
||||
else:
|
||||
# Bearish OBV divergence: price makes higher high but OBV makes lower high
|
||||
price_highs = window["high"]
|
||||
recent_high_pos = price_highs.values.argmax()
|
||||
if recent_high_pos < lb - 5:
|
||||
return False
|
||||
first_half = price_highs.iloc[:lb // 2]
|
||||
if len(first_half) < 3:
|
||||
return False
|
||||
prev_high_pos = first_half.values.argmax()
|
||||
if (price_highs.iloc[recent_high_pos] > first_half.iloc[prev_high_pos] and
|
||||
obv_vals.iloc[recent_high_pos] < obv_vals.iloc[prev_high_pos]):
|
||||
return True
|
||||
return False
|
||||
|
||||
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-17:00 UTC
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# HTF trend alignment
|
||||
if htf_row is None:
|
||||
return None
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if np.isnan(htf_ema200) or np.isnan(htf_close):
|
||||
return None
|
||||
|
||||
htf_bullish = htf_close > htf_ema200
|
||||
htf_bearish = htf_close < htf_ema200
|
||||
|
||||
price = current["close"]
|
||||
candle_high = current["high"]
|
||||
candle_low = current["low"]
|
||||
|
||||
# RSI as confluence signal (not hard gate — lesson from S4)
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if np.isnan(rsi_val):
|
||||
rsi_val = 50
|
||||
|
||||
# Find swing levels
|
||||
swing_highs, swing_lows = self._find_swing_levels(data, idx)
|
||||
|
||||
# ---- CHECK FOR BULLISH SWEEP (sweep below swing low, then reverse up) ----
|
||||
if htf_bullish:
|
||||
swing_lows = self._find_equal_levels(swing_lows, atr_val)
|
||||
for sw_idx, sw_price in reversed(swing_lows): # Check most recent first
|
||||
penetration = sw_price - candle_low
|
||||
if penetration < self.SWEEP_MIN_ATR * atr_val:
|
||||
continue
|
||||
if penetration > self.SWEEP_MAX_ATR * atr_val:
|
||||
continue
|
||||
# Reversal confirmation: close back above the swing level
|
||||
if price <= sw_price:
|
||||
continue
|
||||
# Strong close: in upper 40% of candle range
|
||||
candle_range = candle_high - candle_low
|
||||
if candle_range <= 0:
|
||||
continue
|
||||
if (price - candle_low) / candle_range < 0.4:
|
||||
continue
|
||||
|
||||
# OBV divergence check (bonus confluence, not hard gate)
|
||||
obv_div = self._check_obv_divergence(data, idx, "LONG")
|
||||
confluence = 3 + (1 if obv_div else 0)
|
||||
|
||||
# RSI in oversold zone adds confluence (soft, not hard gate)
|
||||
if rsi_val < 40:
|
||||
confluence += 1
|
||||
|
||||
# Volume confirmation
|
||||
vol = current.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.5 * vol_avg:
|
||||
confluence += 1
|
||||
|
||||
sweep_extreme = candle_low
|
||||
sl = sweep_extreme - self.SL_ATR_MULT * atr_val
|
||||
tp1 = price + self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price + self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": "LONG",
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "liquidity_sweep_bullish",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
# ---- CHECK FOR BEARISH SWEEP (sweep above swing high, then reverse down) ----
|
||||
if htf_bearish:
|
||||
swing_highs = self._find_equal_levels(swing_highs, atr_val)
|
||||
for sw_idx, sw_price in reversed(swing_highs):
|
||||
penetration = candle_high - sw_price
|
||||
if penetration < self.SWEEP_MIN_ATR * atr_val:
|
||||
continue
|
||||
if penetration > self.SWEEP_MAX_ATR * atr_val:
|
||||
continue
|
||||
# Reversal: close back below the swing level
|
||||
if price >= sw_price:
|
||||
continue
|
||||
# Strong close: in lower 40% of candle range
|
||||
candle_range = candle_high - candle_low
|
||||
if candle_range <= 0:
|
||||
continue
|
||||
if (candle_high - price) / candle_range < 0.4:
|
||||
continue
|
||||
|
||||
obv_div = self._check_obv_divergence(data, idx, "SHORT")
|
||||
confluence = 3 + (1 if obv_div else 0)
|
||||
|
||||
if rsi_val > 60:
|
||||
confluence += 1
|
||||
|
||||
vol = current.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, idx - 20):idx].mean()
|
||||
if vol_avg > 0 and vol > 1.5 * vol_avg:
|
||||
confluence += 1
|
||||
|
||||
sweep_extreme = candle_high
|
||||
sl = sweep_extreme + self.SL_ATR_MULT * atr_val
|
||||
tp1 = price - self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price - self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": "SHORT",
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": "liquidity_sweep_bearish",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Strategy S8: Order Block Retest.
|
||||
|
||||
Concept: Price returns to the last opposing candle before a strong impulse
|
||||
move (displacement), and bounces from it. Treated as classical supply/demand
|
||||
zones with strict confluence filters (not mythical "institutional footprints").
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Detect a displacement candle: body >= 1.5 ATR (strong impulse)
|
||||
2. Identify the order block: last opposing candle before displacement
|
||||
3. Price returns to retest the OB zone (touches OB body range)
|
||||
4. Rejection candle at OB: pin bar (wick >= 2x body) or engulfing pattern
|
||||
5. At least 2 of 3 confluence factors:
|
||||
a) FVG exists within the impulse move
|
||||
b) OB is at a broken S/R level (structural confluence)
|
||||
c) Volume declining on pullback into OB
|
||||
6. HTF (H1) trend alignment via 200 EMA
|
||||
7. Session filter: 08:00-17:00 UTC
|
||||
|
||||
Exit:
|
||||
- SL: OB body low/high + 0.3 ATR buffer (NOT the full wick)
|
||||
- TP1: 1.5 ATR from entry (close 50%)
|
||||
- TP2: 3.0 ATR from entry (close 50%)
|
||||
- Max hold: 40 bars
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S8_Order_Block(BaseStrategy):
|
||||
strategy_id = 8
|
||||
name = "S8_Order_Block"
|
||||
|
||||
# Tunable parameters
|
||||
DISPLACEMENT_ATR = 1.5 # Min body size for displacement candle
|
||||
DISPLACEMENT_VOL = 1.5 # Min volume ratio for displacement
|
||||
OB_LOOKBACK = 50 # How far back to search for OBs
|
||||
OB_RETEST_WINDOW = 30 # Max bars for price to retest OB after displacement
|
||||
SL_ATR_BUFFER = 0.3 # Buffer beyond OB body for SL
|
||||
TP1_ATR_MULT = 1.5
|
||||
TP2_ATR_MULT = 3.0
|
||||
MAX_BARS = 40
|
||||
|
||||
def _find_order_blocks(self, data, idx, atr_val):
|
||||
"""
|
||||
Find valid order blocks: last opposing candle before a displacement move.
|
||||
Returns list of dicts: {direction, ob_idx, ob_body_high, ob_body_low,
|
||||
ob_high, ob_low, displacement_idx, has_fvg}
|
||||
"""
|
||||
order_blocks = []
|
||||
start = max(0, idx - self.OB_LOOKBACK)
|
||||
|
||||
for i in range(start + 1, idx - 2):
|
||||
curr = data.iloc[i]
|
||||
body = abs(curr["close"] - curr["open"])
|
||||
|
||||
# Is this a displacement candle? (body >= 1.5 ATR)
|
||||
bar_atr = curr.get("atr_14", atr_val)
|
||||
if np.isnan(bar_atr) or bar_atr <= 0:
|
||||
bar_atr = atr_val
|
||||
if body < self.DISPLACEMENT_ATR * bar_atr:
|
||||
continue
|
||||
|
||||
# Volume confirmation for displacement
|
||||
vol = curr.get("volume", 0)
|
||||
vol_avg = data["volume"].iloc[max(0, i - 20):i].mean()
|
||||
if vol_avg > 0 and vol < self.DISPLACEMENT_VOL * vol_avg:
|
||||
continue
|
||||
|
||||
is_bullish_displacement = curr["close"] > curr["open"]
|
||||
is_bearish_displacement = curr["close"] < curr["open"]
|
||||
|
||||
if not (is_bullish_displacement or is_bearish_displacement):
|
||||
continue
|
||||
|
||||
# Find the order block: last OPPOSING candle before displacement
|
||||
ob_idx = None
|
||||
for j in range(i - 1, max(start, i - 10) - 1, -1):
|
||||
ob_candle = data.iloc[j]
|
||||
ob_bullish = ob_candle["close"] > ob_candle["open"]
|
||||
ob_bearish = ob_candle["close"] < ob_candle["open"]
|
||||
|
||||
if is_bullish_displacement and ob_bearish:
|
||||
ob_idx = j
|
||||
break
|
||||
elif is_bearish_displacement and ob_bullish:
|
||||
ob_idx = j
|
||||
break
|
||||
|
||||
if ob_idx is None:
|
||||
continue
|
||||
|
||||
ob = data.iloc[ob_idx]
|
||||
ob_body_high = max(ob["open"], ob["close"])
|
||||
ob_body_low = min(ob["open"], ob["close"])
|
||||
|
||||
# Check for FVG in the impulse move
|
||||
has_fvg = False
|
||||
if i >= 2:
|
||||
candle_before = data.iloc[i - 1]
|
||||
candle_after_idx = min(i + 1, len(data) - 1)
|
||||
candle_after = data.iloc[candle_after_idx]
|
||||
if is_bullish_displacement:
|
||||
# Bullish FVG: candle[i-1].high < candle[i+1].low
|
||||
if candle_before["high"] < candle_after["low"]:
|
||||
has_fvg = True
|
||||
else:
|
||||
# Bearish FVG: candle[i-1].low > candle[i+1].high
|
||||
if candle_before["low"] > candle_after["high"]:
|
||||
has_fvg = True
|
||||
|
||||
direction = "LONG" if is_bullish_displacement else "SHORT"
|
||||
order_blocks.append({
|
||||
"direction": direction,
|
||||
"ob_idx": ob_idx,
|
||||
"ob_body_high": ob_body_high,
|
||||
"ob_body_low": ob_body_low,
|
||||
"ob_high": ob["high"],
|
||||
"ob_low": ob["low"],
|
||||
"displacement_idx": i,
|
||||
"has_fvg": has_fvg,
|
||||
})
|
||||
|
||||
return order_blocks
|
||||
|
||||
def _is_rejection_candle(self, candle, direction):
|
||||
"""Check if candle shows rejection (pin bar or engulfing-like)."""
|
||||
body = abs(candle["close"] - candle["open"])
|
||||
full_range = candle["high"] - candle["low"]
|
||||
if full_range <= 0:
|
||||
return False
|
||||
|
||||
if direction == "LONG":
|
||||
lower_wick = min(candle["open"], candle["close"]) - candle["low"]
|
||||
# Pin bar: lower wick >= 2x body, close in upper 40%
|
||||
if lower_wick >= 2 * body and (candle["close"] - candle["low"]) / full_range >= 0.6:
|
||||
return True
|
||||
# Bullish candle with strong close
|
||||
if candle["close"] > candle["open"] and body / full_range >= 0.5:
|
||||
return True
|
||||
else:
|
||||
upper_wick = candle["high"] - max(candle["open"], candle["close"])
|
||||
# Pin bar: upper wick >= 2x body, close in lower 40%
|
||||
if upper_wick >= 2 * body and (candle["high"] - candle["close"]) / full_range >= 0.6:
|
||||
return True
|
||||
# Bearish candle with strong close
|
||||
if candle["close"] < candle["open"] and body / full_range >= 0.5:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_volume_declining(self, data, displacement_idx, idx):
|
||||
"""Check if volume is declining on the pullback to OB."""
|
||||
if idx <= displacement_idx + 2:
|
||||
return False
|
||||
displacement_vol = data["volume"].iloc[displacement_idx]
|
||||
pullback_vol = data["volume"].iloc[displacement_idx + 1:idx + 1].mean()
|
||||
return pullback_vol < 0.8 * displacement_vol
|
||||
|
||||
def _is_at_broken_sr(self, data, idx, ob_price, atr_val):
|
||||
"""Check if OB is at a level where prior S/R was broken (structural confluence)."""
|
||||
# Look for swing highs/lows near the OB price that were broken
|
||||
tolerance = 0.5 * atr_val
|
||||
lookback_start = max(0, idx - 200)
|
||||
|
||||
for i in range(lookback_start, idx - 20):
|
||||
bar = data.iloc[i]
|
||||
is_sh = bar.get("is_swing_high", False)
|
||||
is_sl_point = bar.get("is_swing_low", False)
|
||||
|
||||
if is_sh and abs(bar["high"] - ob_price) < tolerance:
|
||||
return True
|
||||
if is_sl_point and abs(bar["low"] - ob_price) < tolerance:
|
||||
return True
|
||||
return False
|
||||
|
||||
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
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
if hour < 8 or hour >= 17:
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# HTF trend alignment
|
||||
if htf_row is None:
|
||||
return None
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if np.isnan(htf_ema200) or np.isnan(htf_close):
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# Find order blocks
|
||||
order_blocks = self._find_order_blocks(data, idx, atr_val)
|
||||
|
||||
for ob in order_blocks:
|
||||
# Only trade OBs aligned with HTF trend
|
||||
if ob["direction"] == "LONG" and htf_close < htf_ema200:
|
||||
continue
|
||||
if ob["direction"] == "SHORT" and htf_close > htf_ema200:
|
||||
continue
|
||||
|
||||
# Check if price is retesting the OB zone
|
||||
# For LONG: price should be in or near the OB body zone (pullback down into it)
|
||||
if ob["direction"] == "LONG":
|
||||
if not (current["low"] <= ob["ob_body_high"] and price >= ob["ob_body_low"]):
|
||||
continue
|
||||
else:
|
||||
if not (current["high"] >= ob["ob_body_low"] and price <= ob["ob_body_high"]):
|
||||
continue
|
||||
|
||||
# Check OB is not too old (retest within window)
|
||||
bars_since = idx - ob["displacement_idx"]
|
||||
if bars_since > self.OB_RETEST_WINDOW or bars_since < 3:
|
||||
continue
|
||||
|
||||
# Rejection candle check
|
||||
if not self._is_rejection_candle(current, ob["direction"]):
|
||||
continue
|
||||
|
||||
# Confluence scoring (need 2 of 3)
|
||||
confluence_count = 0
|
||||
if ob["has_fvg"]:
|
||||
confluence_count += 1
|
||||
if self._check_volume_declining(data, ob["displacement_idx"], idx):
|
||||
confluence_count += 1
|
||||
ob_mid = (ob["ob_body_high"] + ob["ob_body_low"]) / 2
|
||||
if self._is_at_broken_sr(data, idx, ob_mid, atr_val):
|
||||
confluence_count += 1
|
||||
|
||||
if confluence_count < 2:
|
||||
continue
|
||||
|
||||
# Build exit levels using OB BODY (not wick) + buffer
|
||||
if ob["direction"] == "LONG":
|
||||
sl = ob["ob_body_low"] - self.SL_ATR_BUFFER * atr_val
|
||||
tp1 = price + self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price + self.TP2_ATR_MULT * atr_val
|
||||
else:
|
||||
sl = ob["ob_body_high"] + self.SL_ATR_BUFFER * atr_val
|
||||
tp1 = price - self.TP1_ATR_MULT * atr_val
|
||||
tp2 = price - self.TP2_ATR_MULT * atr_val
|
||||
|
||||
return {
|
||||
"direction": ob["direction"],
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": min(confluence_count + 2, 5),
|
||||
"entry_pattern": f"order_block_retest_{ob['direction'].lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
Strategy S9: London Session Gap (Asian Range Breakout).
|
||||
|
||||
Concept: Price breaks out of the Asian session range at London open, driven
|
||||
by institutional order flow from European/UK desks. Session-based volatility
|
||||
patterns are among the most well-documented phenomena in FX
|
||||
(Andersen & Bollerslev 1997, BIS data).
|
||||
|
||||
Entry conditions (ALL must be true):
|
||||
1. Asian range defined: 00:00-07:00 UTC high/low
|
||||
2. Asian range not too wide (< 1.5 ATR H1 and < pair-specific cap)
|
||||
3. Price breaks above Asian high (LONG) or below Asian low (SHORT)
|
||||
with a candle CLOSE beyond the level
|
||||
4. Volume > 3.0x Asian session average (first London candle almost always
|
||||
shows 2x, so 3x filters for meaningful surges)
|
||||
5. ADX > 20 (some trending context)
|
||||
6. Trade window: 07:00-10:00 UTC (London kill zone)
|
||||
|
||||
Exit:
|
||||
- SL: Opposite side of Asian range, capped at 1.5 ATR(H1) or pip limit
|
||||
- TP1: Asian range width as measured-move target (close 50%)
|
||||
- TP2: 2.0x Asian range width (close 50%)
|
||||
- Time exit: 17:00 UTC (captures full London-NY overlap)
|
||||
- Max hold: 40 bars (H1)
|
||||
"""
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from .base import BaseStrategy
|
||||
|
||||
|
||||
class S9_London_Session(BaseStrategy):
|
||||
strategy_id = 9
|
||||
name = "S9_London_Session"
|
||||
|
||||
# Asian range window (UTC hours)
|
||||
ASIAN_START_HOUR = 0
|
||||
ASIAN_END_HOUR = 7
|
||||
|
||||
# Entry window (UTC hours)
|
||||
ENTRY_START_HOUR = 7
|
||||
ENTRY_END_HOUR = 10
|
||||
|
||||
# Exit time (UTC hour) — captures full London-NY overlap
|
||||
TIME_EXIT_HOUR = 17
|
||||
|
||||
# Volume threshold (relaxed from 3.0 for H1 — Asian H1 bars aren't dramatically
|
||||
# lower volume than London H1 bars the way M15 bars would be)
|
||||
VOLUME_MULT = 1.5
|
||||
|
||||
# Max Asian range (in pips) per pair category
|
||||
MAX_RANGE_PIPS = {
|
||||
"EUR_USD": 60, "GBP_USD": 80, "EUR_AUD": 80,
|
||||
"GBP_AUD": 100, "GBP_JPY": 100, "USD_JPY": 60,
|
||||
"EUR_CAD": 80, "GBP_CAD": 100, "EUR_GBP": 50,
|
||||
}
|
||||
|
||||
# SL cap in ATR (widened from 1.5 — was filtering out most days)
|
||||
SL_ATR_CAP = 2.5
|
||||
|
||||
MAX_BARS = 40
|
||||
|
||||
# Per-pair filter overrides (set via constructor with pair= and filtered=True)
|
||||
# Each key maps to a dict of: min_adx, rsi_neutral_skip, skip_friday,
|
||||
# entry_start_hour, tp1_mult, min_ema50_dist_pips
|
||||
PAIR_FILTERS = {
|
||||
"EUR_USD": {
|
||||
"tp1_mult": 1.5, # TP1 = 1.5x Asian range (was 1.0x)
|
||||
# NOTE: RSI filter and ADX hard gate tested but overfit — dropped
|
||||
},
|
||||
"GBP_AUD": {
|
||||
"min_adx": 25, # require ADX > 25
|
||||
"skip_friday": True, # drop Friday trades
|
||||
"min_ema50_dist_pips": 40, # require 40+ pips from EMA50
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, pair=None, filtered=False):
|
||||
super().__init__()
|
||||
self._asian_range_cache = {} # date -> (high, low, avg_vol)
|
||||
self._pair = pair
|
||||
self._filtered = filtered
|
||||
self._pair_cfg = {}
|
||||
if filtered and pair and pair in self.PAIR_FILTERS:
|
||||
self._pair_cfg = self.PAIR_FILTERS[pair]
|
||||
|
||||
def _get_pip_size(self, pair):
|
||||
if "JPY" in pair:
|
||||
return 0.01
|
||||
return 0.0001
|
||||
|
||||
def _compute_asian_range(self, data, idx):
|
||||
"""Compute Asian session range for the current day."""
|
||||
current_time = data.index[idx]
|
||||
current_date = current_time.date()
|
||||
|
||||
if current_date in self._asian_range_cache:
|
||||
return self._asian_range_cache[current_date]
|
||||
|
||||
# Find Asian session bars for today (00:00-07:00 UTC)
|
||||
asian_bars = []
|
||||
for i in range(max(0, idx - 50), idx + 1):
|
||||
bar_time = data.index[i]
|
||||
if bar_time.date() != current_date:
|
||||
continue
|
||||
bar_hour = bar_time.hour
|
||||
if self.ASIAN_START_HOUR <= bar_hour < self.ASIAN_END_HOUR:
|
||||
asian_bars.append(i)
|
||||
|
||||
if len(asian_bars) < 3:
|
||||
return None
|
||||
|
||||
asian_data = data.iloc[asian_bars]
|
||||
asian_high = asian_data["high"].max()
|
||||
asian_low = asian_data["low"].min()
|
||||
asian_avg_vol = asian_data["volume"].mean()
|
||||
|
||||
result = (asian_high, asian_low, asian_avg_vol)
|
||||
self._asian_range_cache[current_date] = result
|
||||
return result
|
||||
|
||||
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
|
||||
|
||||
# Entry window (per-pair override for start hour)
|
||||
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
||||
start_hour = self._pair_cfg.get("entry_start_hour", self.ENTRY_START_HOUR)
|
||||
if hour < start_hour or hour >= self.ENTRY_END_HOUR:
|
||||
return None
|
||||
|
||||
# Friday filter (GBP_AUD: Friday position squaring kills breakouts)
|
||||
if self._pair_cfg.get("skip_friday", False):
|
||||
dow = current.name.dayofweek if hasattr(current.name, 'dayofweek') else 0
|
||||
if dow == 4: # Friday
|
||||
return None
|
||||
|
||||
atr_val = current.get("atr_14", 0)
|
||||
if atr_val <= 0 or np.isnan(atr_val):
|
||||
return None
|
||||
|
||||
# Get HTF ATR for SL capping
|
||||
htf_atr = atr_val
|
||||
if htf_row is not None:
|
||||
htf_atr_val = htf_row.get("atr_14", np.nan)
|
||||
if not np.isnan(htf_atr_val) and htf_atr_val > 0:
|
||||
htf_atr = htf_atr_val
|
||||
|
||||
# Compute Asian range
|
||||
asian = self._compute_asian_range(data, idx)
|
||||
if asian is None:
|
||||
return None
|
||||
|
||||
asian_high, asian_low, asian_avg_vol = asian
|
||||
asian_range = asian_high - asian_low
|
||||
|
||||
if asian_range <= 0:
|
||||
return None
|
||||
|
||||
# Check Asian range not too wide
|
||||
pair = ""
|
||||
# Try to infer pair from strategy context; use default cap
|
||||
max_range_pips = 80 # default
|
||||
pip_size = self._get_pip_size("GBP_JPY" if atr_val > 0.005 else "EUR_USD")
|
||||
range_pips = asian_range / pip_size
|
||||
|
||||
# Cap: skip if Asian range > 1.5 ATR(H1)
|
||||
if asian_range > self.SL_ATR_CAP * htf_atr:
|
||||
return None
|
||||
|
||||
price = current["close"]
|
||||
|
||||
# ADX filter — hard gate when filtered, soft confluence otherwise
|
||||
adx_val = current.get("adx_14", 0)
|
||||
if np.isnan(adx_val):
|
||||
adx_val = 0
|
||||
min_adx = self._pair_cfg.get("min_adx", 0)
|
||||
if min_adx > 0 and adx_val < min_adx:
|
||||
return None
|
||||
adx_strong = adx_val > 20
|
||||
|
||||
# RSI neutral zone filter (EUR_USD: skip RSI 40-60 — no directional momentum)
|
||||
if self._pair_cfg.get("rsi_neutral_skip", False):
|
||||
rsi_val = current.get("rsi_14", 50)
|
||||
if not np.isnan(rsi_val) and 40 <= rsi_val <= 60:
|
||||
return None
|
||||
|
||||
# EMA50 distance filter (GBP_AUD: close-to-EMA trades underperform)
|
||||
min_ema_dist = self._pair_cfg.get("min_ema50_dist_pips", 0)
|
||||
if min_ema_dist > 0:
|
||||
ema50 = current.get("ema_50", np.nan)
|
||||
if not np.isnan(ema50) and ema50 > 0:
|
||||
pip_sz = self._get_pip_size(self._pair or "EUR_USD")
|
||||
dist_pips = abs(price - ema50) / pip_sz
|
||||
if dist_pips < min_ema_dist:
|
||||
return None
|
||||
|
||||
# Volume check: current volume > 1.5x Asian average
|
||||
vol = current.get("volume", 0)
|
||||
if asian_avg_vol <= 0 or vol < self.VOLUME_MULT * asian_avg_vol:
|
||||
return None
|
||||
|
||||
# Direction: breakout above or below Asian range
|
||||
direction = None
|
||||
if price > asian_high and current["close"] > asian_high:
|
||||
direction = "LONG"
|
||||
elif price < asian_low and current["close"] < asian_low:
|
||||
direction = "SHORT"
|
||||
|
||||
if direction is None:
|
||||
return None
|
||||
|
||||
# HTF trend alignment (soft: adds confluence but doesn't block)
|
||||
htf_aligned = False
|
||||
if htf_row is not None:
|
||||
htf_ema200 = htf_row.get("ema_200", np.nan)
|
||||
htf_close = htf_row.get("close", np.nan)
|
||||
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
||||
if direction == "LONG" and htf_close > htf_ema200:
|
||||
htf_aligned = True
|
||||
elif direction == "SHORT" and htf_close < htf_ema200:
|
||||
htf_aligned = True
|
||||
|
||||
confluence = 3 + (1 if htf_aligned else 0) + (1 if adx_strong else 0)
|
||||
|
||||
# SL: opposite side of Asian range, capped
|
||||
tp1_mult = self._pair_cfg.get("tp1_mult", 1.0)
|
||||
|
||||
if direction == "LONG":
|
||||
raw_sl = asian_low
|
||||
sl_distance = price - raw_sl
|
||||
max_sl_distance = self.SL_ATR_CAP * htf_atr
|
||||
if sl_distance > max_sl_distance:
|
||||
raw_sl = price - max_sl_distance
|
||||
sl = raw_sl
|
||||
|
||||
tp1 = price + tp1_mult * asian_range
|
||||
tp2 = price + 2.0 * asian_range
|
||||
else:
|
||||
raw_sl = asian_high
|
||||
sl_distance = raw_sl - price
|
||||
max_sl_distance = self.SL_ATR_CAP * htf_atr
|
||||
if sl_distance > max_sl_distance:
|
||||
raw_sl = price + max_sl_distance
|
||||
sl = raw_sl
|
||||
|
||||
tp1 = price - tp1_mult * asian_range
|
||||
tp2 = price - 2.0 * asian_range
|
||||
|
||||
# Calculate max bars until 17:00 UTC time exit
|
||||
# On H1: roughly 17 - current_hour bars; on M15: (17-hour)*4
|
||||
# Use generic max_bars as fallback
|
||||
hours_remaining = self.TIME_EXIT_HOUR - hour
|
||||
if hours_remaining <= 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"sl": sl,
|
||||
"tp1": tp1,
|
||||
"tp2": tp2,
|
||||
"tp3": tp2,
|
||||
"confluence": confluence,
|
||||
"entry_pattern": f"london_breakout_{direction.lower()}",
|
||||
"tp_splits": (0.50, 0.50, 0.0),
|
||||
"trail_atr_mult": 1.5,
|
||||
"max_bars": self.MAX_BARS,
|
||||
}
|
||||
Reference in New Issue
Block a user