a8d01995ab
Backtest #24B results: 739 trades, 80.4% WR, $2,235 PnL, 3.4% DD, Sharpe 2.87, PF 1.77 (+$785 vs baseline) Three proven improvements: - Skip Tokyo-London overlap session (15:00-16:00 WIB) — backtest +$345 - Relax early cut momentum threshold from -30 to -50 — backtest +$125 - ATR-adaptive breakeven/trail (BE=2.0x ATR, trail_start=4.0x ATR, trail_step=3.0x ATR) — backtest +$373 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""
|
|
Trading Session Filter
|
|
======================
|
|
Filter trades based on market sessions and optimal trading hours.
|
|
Timezone: WIB (Waktu Indonesia Barat) - GMT+7 for Batam/Jakarta.
|
|
|
|
Optimal Trading Hours for XAUUSD:
|
|
- London-NY Overlap: 20:00 - 00:00 WIB (BEST)
|
|
- London Session: 15:00 - 00:00 WIB
|
|
- NY Session: 20:00 - 05:00 WIB
|
|
|
|
Dangerous Zones:
|
|
- Rollover/Spread Wide: 04:00 - 06:00 WIB
|
|
- Low Liquidity: 00:00 - 04:00 WIB
|
|
- Friday Close: After 23:00 WIB Friday
|
|
"""
|
|
|
|
from datetime import datetime, time, timedelta
|
|
from typing import Tuple, Dict, Optional
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from loguru import logger
|
|
import pytz
|
|
|
|
|
|
class TradingSession(Enum):
|
|
"""Market trading sessions."""
|
|
SYDNEY = "sydney"
|
|
TOKYO = "tokyo"
|
|
LONDON = "london"
|
|
NEW_YORK = "new_york"
|
|
OVERLAP_TOKYO_LONDON = "tokyo_london_overlap"
|
|
OVERLAP_LONDON_NY = "london_ny_overlap"
|
|
OFF_HOURS = "off_hours"
|
|
|
|
|
|
@dataclass
|
|
class SessionConfig:
|
|
"""Session trading configuration."""
|
|
name: str
|
|
start_hour: int # WIB
|
|
start_minute: int
|
|
end_hour: int # WIB
|
|
end_minute: int
|
|
volatility: str # "low", "medium", "high", "extreme"
|
|
allow_trading: bool
|
|
position_size_multiplier: float
|
|
|
|
|
|
class SessionFilter:
|
|
"""
|
|
Trading session filter for optimal trading hours.
|
|
|
|
Configured for XAUUSD aggressive trading during London/NY overlap.
|
|
All times in WIB (GMT+7).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
timezone: str = "Asia/Jakarta", # WIB
|
|
aggressive_mode: bool = True, # Focus on high volatility
|
|
):
|
|
self.tz = pytz.timezone(timezone)
|
|
self.aggressive_mode = aggressive_mode
|
|
|
|
# Define trading windows (WIB)
|
|
self.sessions = {
|
|
# Main sessions
|
|
TradingSession.SYDNEY: SessionConfig(
|
|
name="Sydney",
|
|
start_hour=6, start_minute=0, # Start after rollover (skip 04:00-06:00)
|
|
end_hour=13, end_minute=0,
|
|
volatility="low",
|
|
allow_trading=True, # ENABLED - backtest shows $5,934 profit!
|
|
position_size_multiplier=0.5, # HALF lot size for safety
|
|
),
|
|
TradingSession.TOKYO: SessionConfig(
|
|
name="Tokyo",
|
|
start_hour=7, start_minute=0,
|
|
end_hour=16, end_minute=0,
|
|
volatility="medium",
|
|
allow_trading=True,
|
|
position_size_multiplier=0.7,
|
|
),
|
|
TradingSession.LONDON: SessionConfig(
|
|
name="London",
|
|
start_hour=15, start_minute=0,
|
|
end_hour=23, end_minute=59,
|
|
volatility="high",
|
|
allow_trading=True,
|
|
position_size_multiplier=1.0,
|
|
),
|
|
TradingSession.NEW_YORK: SessionConfig(
|
|
name="New York",
|
|
start_hour=20, start_minute=0,
|
|
end_hour=23, end_minute=59, # NY continues past midnight
|
|
volatility="extreme",
|
|
allow_trading=True,
|
|
position_size_multiplier=1.0,
|
|
),
|
|
# Overlap sessions (BEST TIMES)
|
|
TradingSession.OVERLAP_TOKYO_LONDON: SessionConfig(
|
|
name="Tokyo-London Overlap",
|
|
start_hour=15, start_minute=0,
|
|
end_hour=16, end_minute=0,
|
|
volatility="high",
|
|
allow_trading=False, # #24B: Skip Tokyo-London overlap (backtest +$345)
|
|
position_size_multiplier=0.0,
|
|
),
|
|
TradingSession.OVERLAP_LONDON_NY: SessionConfig(
|
|
name="London-NY Overlap (GOLDEN)",
|
|
start_hour=20, start_minute=0,
|
|
end_hour=23, end_minute=59,
|
|
volatility="extreme",
|
|
allow_trading=True,
|
|
position_size_multiplier=1.2, # Boost during golden hours
|
|
),
|
|
}
|
|
|
|
# Danger zones (WIB)
|
|
self.danger_zones = [
|
|
# Rollover - spread extremely wide
|
|
{"name": "Rollover", "start": (4, 0), "end": (6, 0), "reason": "Spread melebar saat rollover"},
|
|
# Low liquidity
|
|
{"name": "Dead Zone", "start": (0, 0), "end": (4, 0), "reason": "Likuiditas rendah, spread tinggi"},
|
|
]
|
|
|
|
# High impact news times to avoid (typical release times in WIB)
|
|
self.news_blackout_times = [
|
|
# NFP - First Friday of month
|
|
{"event": "NFP", "hour": 19, "minute": 30, "buffer_before": 15, "buffer_after": 30},
|
|
# Fed Interest Rate
|
|
{"event": "FOMC", "hour": 1, "minute": 0, "buffer_before": 15, "buffer_after": 45},
|
|
# US CPI
|
|
{"event": "CPI", "hour": 19, "minute": 30, "buffer_before": 15, "buffer_after": 30},
|
|
]
|
|
|
|
def get_current_time_wib(self) -> datetime:
|
|
"""Get current time in WIB."""
|
|
return datetime.now(self.tz)
|
|
|
|
def get_current_session(self) -> Tuple[TradingSession, SessionConfig]:
|
|
"""
|
|
Get the current trading session.
|
|
|
|
Returns highest priority session if multiple overlap.
|
|
Priority: Overlap > London/NY > Tokyo > Sydney > Off Hours
|
|
"""
|
|
now = self.get_current_time_wib()
|
|
hour = now.hour
|
|
minute = now.minute
|
|
current_time = hour * 60 + minute
|
|
|
|
# Check overlaps first (highest priority)
|
|
if 20 * 60 <= current_time <= 24 * 60: # 20:00 - 00:00
|
|
return TradingSession.OVERLAP_LONDON_NY, self.sessions[TradingSession.OVERLAP_LONDON_NY]
|
|
|
|
if 15 * 60 <= current_time <= 16 * 60: # 15:00 - 16:00
|
|
return TradingSession.OVERLAP_TOKYO_LONDON, self.sessions[TradingSession.OVERLAP_TOKYO_LONDON]
|
|
|
|
# Check main sessions
|
|
for session, config in self.sessions.items():
|
|
if session in [TradingSession.OVERLAP_LONDON_NY, TradingSession.OVERLAP_TOKYO_LONDON]:
|
|
continue
|
|
|
|
start = config.start_hour * 60 + config.start_minute
|
|
end = config.end_hour * 60 + config.end_minute
|
|
|
|
if start <= current_time <= end:
|
|
return session, config
|
|
|
|
# Off hours
|
|
return TradingSession.OFF_HOURS, SessionConfig(
|
|
name="Off Hours",
|
|
start_hour=0, start_minute=0,
|
|
end_hour=0, end_minute=0,
|
|
volatility="low",
|
|
allow_trading=False,
|
|
position_size_multiplier=0.0,
|
|
)
|
|
|
|
def is_danger_zone(self) -> Tuple[bool, str]:
|
|
"""Check if current time is in a danger zone."""
|
|
now = self.get_current_time_wib()
|
|
hour = now.hour
|
|
minute = now.minute
|
|
current_time = hour * 60 + minute
|
|
|
|
for zone in self.danger_zones:
|
|
start = zone["start"][0] * 60 + zone["start"][1]
|
|
end = zone["end"][0] * 60 + zone["end"][1]
|
|
|
|
if start <= current_time < end:
|
|
return True, zone["reason"]
|
|
|
|
return False, ""
|
|
|
|
def is_friday_close(self) -> bool:
|
|
"""Check if approaching Friday market close (Saturday 05:00 WIB)."""
|
|
now = self.get_current_time_wib()
|
|
# Market closes Saturday 05:00 WIB — only block 30 min before
|
|
# Saturday 04:30+ WIB
|
|
if now.weekday() == 5 and now.hour == 4 and now.minute >= 30:
|
|
return True
|
|
return False
|
|
|
|
def is_weekend(self) -> bool:
|
|
"""Check if market is closed (weekend)."""
|
|
now = self.get_current_time_wib()
|
|
weekday = now.weekday()
|
|
|
|
# Saturday full day
|
|
if weekday == 5:
|
|
return True
|
|
# Sunday until 04:00 WIB Monday
|
|
if weekday == 6:
|
|
return True
|
|
# Saturday early morning (before market close at 05:00)
|
|
if weekday == 5 and now.hour < 5:
|
|
return False # Market still open
|
|
|
|
return False
|
|
|
|
def can_trade(self) -> Tuple[bool, str, float]:
|
|
"""
|
|
Check if trading is allowed right now.
|
|
|
|
Returns:
|
|
Tuple of (can_trade, reason, position_multiplier)
|
|
"""
|
|
now = self.get_current_time_wib()
|
|
|
|
# Check weekend
|
|
if self.is_weekend():
|
|
return False, "Market tutup (weekend)", 0.0
|
|
|
|
# Check Friday close
|
|
if self.is_friday_close():
|
|
return False, "Mendekati penutupan Jumat - hindari gap weekend", 0.0
|
|
|
|
# Check danger zones
|
|
is_danger, danger_reason = self.is_danger_zone()
|
|
if is_danger:
|
|
return False, f"Zona bahaya: {danger_reason}", 0.0
|
|
|
|
# Get current session
|
|
session, config = self.get_current_session()
|
|
|
|
if not config.allow_trading:
|
|
return False, f"Trading tidak diizinkan saat {config.name}", 0.0
|
|
|
|
# In aggressive mode, allow medium+ volatility + Sydney (proven profitable)
|
|
if self.aggressive_mode:
|
|
# Sydney session is ALLOWED - backtest shows 62% WR, $5,934 profit
|
|
if session == TradingSession.SYDNEY:
|
|
return True, f"Trading OK - {config.name} (SAFE MODE: 0.5x lot)", config.position_size_multiplier
|
|
# Only block low volatility sessions
|
|
if config.volatility not in ["medium", "high", "extreme"]:
|
|
return False, f"Mode agresif: tunggu sesi {config.name} (volatilitas {config.volatility})", config.position_size_multiplier
|
|
|
|
return True, f"Trading OK - {config.name} ({config.volatility} volatility)", config.position_size_multiplier
|
|
|
|
def get_next_trading_window(self) -> Dict:
|
|
"""Get when the next optimal trading window starts."""
|
|
now = self.get_current_time_wib()
|
|
current_hour = now.hour
|
|
|
|
# Find next London-NY overlap
|
|
if current_hour < 20:
|
|
# Today at 20:00
|
|
next_window = now.replace(hour=20, minute=0, second=0, microsecond=0)
|
|
hours_until = 20 - current_hour
|
|
else:
|
|
# Tomorrow at 20:00
|
|
next_window = (now + timedelta(days=1)).replace(hour=20, minute=0, second=0, microsecond=0)
|
|
hours_until = 24 - current_hour + 20
|
|
|
|
return {
|
|
"next_window": next_window.strftime("%Y-%m-%d %H:%M WIB"),
|
|
"hours_until": hours_until,
|
|
"session": "London-NY Overlap",
|
|
"is_weekend": self.is_weekend(),
|
|
}
|
|
|
|
def get_status_report(self) -> Dict:
|
|
"""Get comprehensive trading session status."""
|
|
now = self.get_current_time_wib()
|
|
session, config = self.get_current_session()
|
|
can_trade, reason, multiplier = self.can_trade()
|
|
is_danger, danger_reason = self.is_danger_zone()
|
|
|
|
return {
|
|
"current_time": now.strftime("%Y-%m-%d %H:%M:%S WIB"),
|
|
"day_of_week": now.strftime("%A"),
|
|
"current_session": config.name,
|
|
"volatility": config.volatility,
|
|
"can_trade": can_trade,
|
|
"reason": reason,
|
|
"position_multiplier": multiplier,
|
|
"is_danger_zone": is_danger,
|
|
"danger_reason": danger_reason,
|
|
"is_friday_close": self.is_friday_close(),
|
|
"is_weekend": self.is_weekend(),
|
|
"next_window": self.get_next_trading_window(),
|
|
}
|
|
|
|
|
|
# Convenience function
|
|
def create_wib_session_filter(aggressive: bool = True) -> SessionFilter:
|
|
"""Create session filter for WIB timezone."""
|
|
return SessionFilter(
|
|
timezone="Asia/Jakarta",
|
|
aggressive_mode=aggressive,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Test session filter
|
|
sf = create_wib_session_filter(aggressive=True)
|
|
|
|
print("\n" + "=" * 60)
|
|
print("TRADING SESSION STATUS")
|
|
print("=" * 60)
|
|
|
|
status = sf.get_status_report()
|
|
for key, value in status.items():
|
|
print(f"{key}: {value}")
|
|
|
|
print("\n" + "=" * 60)
|
|
can_trade, reason, multiplier = sf.can_trade()
|
|
print(f"Can Trade: {can_trade}")
|
|
print(f"Reason: {reason}")
|
|
print(f"Position Multiplier: {multiplier}")
|