mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-20 13:38:06 +00:00
Phase 2: Live paper trading engine + extended backtesting analytics
Track A — Live paper trading system: - Extract PositionManager from backtester into shared src/position_manager.py - Refactor backtester/engine.py to delegate to PositionManager - New src/live/ package: data_feed (OANDA polling), executor (paper/live orders), engine (LiveEngine orchestrator with 5 strategy slots), run.py entry point - Add phase2 config to system.yaml (S7_Tight, S9, S9_Filtered, S4F, S3) Track B — Extended backtesting analytics: - Regime analysis: per-year (2021-2023) breakdown shows 4/5 strategies trending UP - Correlation analysis: S7+S3 GBP_JPY overlap=16.9% (moderate), S9 pairs=12% (low) - Kelly sizing: S9_Filtered half-Kelly=7.3%, S4F=2.4%, S3=1.6% with Monte Carlo DD Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
39a6536284
commit
072ac0f245
@@ -102,3 +102,30 @@ supabase:
|
|||||||
url: https://<your>.supabase.co
|
url: https://<your>.supabase.co
|
||||||
key_env_name: SUPABASE_KEY
|
key_env_name: SUPABASE_KEY
|
||||||
table: fx_candles
|
table: fx_candles
|
||||||
|
phase2:
|
||||||
|
strategies:
|
||||||
|
- name: S7_Tight
|
||||||
|
pair: GBP_JPY
|
||||||
|
timeframe: H1
|
||||||
|
enabled: true
|
||||||
|
- name: S9
|
||||||
|
pair: GBP_USD
|
||||||
|
timeframe: H1
|
||||||
|
enabled: true
|
||||||
|
- name: S9_Filtered
|
||||||
|
pair: GBP_AUD
|
||||||
|
timeframe: H1
|
||||||
|
enabled: true
|
||||||
|
- name: S4F
|
||||||
|
pair: EUR_AUD
|
||||||
|
timeframe: M15
|
||||||
|
htf_timeframe: H1
|
||||||
|
enabled: true
|
||||||
|
- name: S3
|
||||||
|
pair: GBP_JPY
|
||||||
|
timeframe: H1
|
||||||
|
enabled: true
|
||||||
|
poll_interval_seconds: 60
|
||||||
|
paper_mode: true
|
||||||
|
starting_equity: 100000
|
||||||
|
max_daily_drawdown_pct: 5.0
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"S7_S3_overlap": {
|
||||||
|
"overlap_count": 21,
|
||||||
|
"same_dir": 21,
|
||||||
|
"opposite_dir": 0,
|
||||||
|
"ratio": 0.169
|
||||||
|
},
|
||||||
|
"S9_S9F_temporal": {
|
||||||
|
"s9_trade_days": 275,
|
||||||
|
"s9f_trade_days": 65,
|
||||||
|
"shared_trade_days": 33,
|
||||||
|
"temporal_overlap_ratio": 0.12
|
||||||
|
},
|
||||||
|
"portfolio": {
|
||||||
|
"total_trades": 694,
|
||||||
|
"win_rate_pct": 53.3,
|
||||||
|
"profit_factor": 0.99,
|
||||||
|
"total_pnl_pips": -175.1,
|
||||||
|
"total_pnl_dollars": -23036.66,
|
||||||
|
"max_drawdown_pct": -47.8,
|
||||||
|
"sharpe_ratio": -0.71
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"S7_Tight_GBP_JPY": {
|
||||||
|
"trades": 98,
|
||||||
|
"win_rate": 59.2,
|
||||||
|
"avg_win_pips": 42.8,
|
||||||
|
"avg_loss_pips": 62.3,
|
||||||
|
"avg_win_loss_ratio": 0.69,
|
||||||
|
"kelly_full_pct": 0,
|
||||||
|
"kelly_half_pct": 0,
|
||||||
|
"monte_carlo_half_kelly": {
|
||||||
|
"p50_dd": 0,
|
||||||
|
"p75_dd": 0,
|
||||||
|
"p95_dd": 0,
|
||||||
|
"p99_dd": 0,
|
||||||
|
"ruin_pct": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S9_GBP_USD": {
|
||||||
|
"trades": 285,
|
||||||
|
"win_rate": 53.7,
|
||||||
|
"avg_win_pips": 29.3,
|
||||||
|
"avg_loss_pips": 41.7,
|
||||||
|
"avg_win_loss_ratio": 0.7,
|
||||||
|
"kelly_full_pct": 0,
|
||||||
|
"kelly_half_pct": 0,
|
||||||
|
"monte_carlo_half_kelly": {
|
||||||
|
"p50_dd": 0,
|
||||||
|
"p75_dd": 0,
|
||||||
|
"p95_dd": 0,
|
||||||
|
"p99_dd": 0,
|
||||||
|
"ruin_pct": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S9_Filtered_GBP_AUD": {
|
||||||
|
"trades": 66,
|
||||||
|
"win_rate": 60.6,
|
||||||
|
"avg_win_pips": 49.5,
|
||||||
|
"avg_loss_pips": 57.9,
|
||||||
|
"avg_win_loss_ratio": 0.85,
|
||||||
|
"kelly_full_pct": 14.53,
|
||||||
|
"kelly_half_pct": 7.26,
|
||||||
|
"monte_carlo_half_kelly": {
|
||||||
|
"p50_dd": 2.5,
|
||||||
|
"p75_dd": 3.3,
|
||||||
|
"p95_dd": 4.8,
|
||||||
|
"p99_dd": 6.3,
|
||||||
|
"ruin_pct": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S4F_EUR_AUD": {
|
||||||
|
"trades": 95,
|
||||||
|
"win_rate": 45.3,
|
||||||
|
"avg_win_pips": 38.8,
|
||||||
|
"avg_loss_pips": 28.7,
|
||||||
|
"avg_win_loss_ratio": 1.35,
|
||||||
|
"kelly_full_pct": 4.7,
|
||||||
|
"kelly_half_pct": 2.35,
|
||||||
|
"monte_carlo_half_kelly": {
|
||||||
|
"p50_dd": 0.7,
|
||||||
|
"p75_dd": 1.0,
|
||||||
|
"p95_dd": 1.5,
|
||||||
|
"p99_dd": 1.9,
|
||||||
|
"ruin_pct": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S3_GBP_JPY": {
|
||||||
|
"trades": 150,
|
||||||
|
"win_rate": 50.7,
|
||||||
|
"avg_win_pips": 43.5,
|
||||||
|
"avg_loss_pips": 41.9,
|
||||||
|
"avg_win_loss_ratio": 1.04,
|
||||||
|
"kelly_full_pct": 3.11,
|
||||||
|
"kelly_half_pct": 1.56,
|
||||||
|
"monte_carlo_half_kelly": {
|
||||||
|
"p50_dd": 0.9,
|
||||||
|
"p75_dd": 1.2,
|
||||||
|
"p95_dd": 1.9,
|
||||||
|
"p99_dd": 2.4,
|
||||||
|
"ruin_pct": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
{
|
||||||
|
"S7_Tight_GBP_JPY": {
|
||||||
|
"pair": "GBP_JPY",
|
||||||
|
"timeframe": "H1",
|
||||||
|
"years": {
|
||||||
|
"2021": {
|
||||||
|
"trades": 34,
|
||||||
|
"wr": 47.1,
|
||||||
|
"pf": 0.47,
|
||||||
|
"pnl_pips": -465.5,
|
||||||
|
"max_dd_pips": -618.3,
|
||||||
|
"expectancy": -13.69
|
||||||
|
},
|
||||||
|
"2022": {
|
||||||
|
"trades": 29,
|
||||||
|
"wr": 58.6,
|
||||||
|
"pf": 0.89,
|
||||||
|
"pnl_pips": -102.1,
|
||||||
|
"max_dd_pips": -412.7,
|
||||||
|
"expectancy": -3.52
|
||||||
|
},
|
||||||
|
"2023": {
|
||||||
|
"trades": 35,
|
||||||
|
"wr": 71.4,
|
||||||
|
"pf": 1.8,
|
||||||
|
"pnl_pips": 558.9,
|
||||||
|
"max_dd_pips": -219.9,
|
||||||
|
"expectancy": 15.97
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"trades": 98,
|
||||||
|
"wr": 59.2,
|
||||||
|
"pf": 1.0,
|
||||||
|
"pnl_pips": -8.6,
|
||||||
|
"max_dd_pips": -723.9,
|
||||||
|
"expectancy": -0.09
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S9_GBP_USD": {
|
||||||
|
"pair": "GBP_USD",
|
||||||
|
"timeframe": "H1",
|
||||||
|
"years": {
|
||||||
|
"2021": {
|
||||||
|
"trades": 138,
|
||||||
|
"wr": 52.2,
|
||||||
|
"pf": 0.71,
|
||||||
|
"pnl_pips": -701.8,
|
||||||
|
"max_dd_pips": -856.6,
|
||||||
|
"expectancy": -5.09
|
||||||
|
},
|
||||||
|
"2022": {
|
||||||
|
"trades": 115,
|
||||||
|
"wr": 51.3,
|
||||||
|
"pf": 0.83,
|
||||||
|
"pnl_pips": -470.0,
|
||||||
|
"max_dd_pips": -1030.5,
|
||||||
|
"expectancy": -4.09
|
||||||
|
},
|
||||||
|
"2023": {
|
||||||
|
"trades": 32,
|
||||||
|
"wr": 68.8,
|
||||||
|
"pf": 1.38,
|
||||||
|
"pnl_pips": 154.8,
|
||||||
|
"max_dd_pips": -132.7,
|
||||||
|
"expectancy": 4.84
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"trades": 285,
|
||||||
|
"wr": 53.7,
|
||||||
|
"pf": 0.82,
|
||||||
|
"pnl_pips": -1017.0,
|
||||||
|
"max_dd_pips": -1775.8,
|
||||||
|
"expectancy": -3.57
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S9_Filtered_GBP_AUD": {
|
||||||
|
"pair": "GBP_AUD",
|
||||||
|
"timeframe": "H1",
|
||||||
|
"years": {
|
||||||
|
"2021": {
|
||||||
|
"trades": 31,
|
||||||
|
"wr": 45.2,
|
||||||
|
"pf": 0.58,
|
||||||
|
"pnl_pips": -412.3,
|
||||||
|
"max_dd_pips": -535.5,
|
||||||
|
"expectancy": -13.3
|
||||||
|
},
|
||||||
|
"2022": {
|
||||||
|
"trades": 23,
|
||||||
|
"wr": 73.9,
|
||||||
|
"pf": 2.57,
|
||||||
|
"pnl_pips": 544.1,
|
||||||
|
"max_dd_pips": -116.9,
|
||||||
|
"expectancy": 23.65
|
||||||
|
},
|
||||||
|
"2023": {
|
||||||
|
"trades": 12,
|
||||||
|
"wr": 75.0,
|
||||||
|
"pf": 2.98,
|
||||||
|
"pnl_pips": 342.6,
|
||||||
|
"max_dd_pips": -49.6,
|
||||||
|
"expectancy": 28.55
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"trades": 66,
|
||||||
|
"wr": 60.6,
|
||||||
|
"pf": 1.32,
|
||||||
|
"pnl_pips": 474.3,
|
||||||
|
"max_dd_pips": -561.2,
|
||||||
|
"expectancy": 7.19
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S4F_EUR_AUD": {
|
||||||
|
"pair": "EUR_AUD",
|
||||||
|
"timeframe": "M15",
|
||||||
|
"years": {
|
||||||
|
"2021": {
|
||||||
|
"trades": 42,
|
||||||
|
"wr": 42.9,
|
||||||
|
"pf": 1.11,
|
||||||
|
"pnl_pips": 57.8,
|
||||||
|
"max_dd_pips": -119.7,
|
||||||
|
"expectancy": 1.38
|
||||||
|
},
|
||||||
|
"2022": {
|
||||||
|
"trades": 33,
|
||||||
|
"wr": 60.6,
|
||||||
|
"pf": 1.79,
|
||||||
|
"pnl_pips": 370.0,
|
||||||
|
"max_dd_pips": -183.7,
|
||||||
|
"expectancy": 11.21
|
||||||
|
},
|
||||||
|
"2023": {
|
||||||
|
"trades": 20,
|
||||||
|
"wr": 25.0,
|
||||||
|
"pf": 0.48,
|
||||||
|
"pnl_pips": -254.7,
|
||||||
|
"max_dd_pips": -337.2,
|
||||||
|
"expectancy": -12.74
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"trades": 95,
|
||||||
|
"wr": 45.3,
|
||||||
|
"pf": 1.12,
|
||||||
|
"pnl_pips": 173.0,
|
||||||
|
"max_dd_pips": -337.2,
|
||||||
|
"expectancy": 1.82
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"S3_GBP_JPY": {
|
||||||
|
"pair": "GBP_JPY",
|
||||||
|
"timeframe": "H1",
|
||||||
|
"years": {
|
||||||
|
"2021": {
|
||||||
|
"trades": 65,
|
||||||
|
"wr": 46.2,
|
||||||
|
"pf": 0.9,
|
||||||
|
"pnl_pips": -112.6,
|
||||||
|
"max_dd_pips": -345.7,
|
||||||
|
"expectancy": -1.73
|
||||||
|
},
|
||||||
|
"2022": {
|
||||||
|
"trades": 47,
|
||||||
|
"wr": 53.2,
|
||||||
|
"pf": 1.21,
|
||||||
|
"pnl_pips": 262.9,
|
||||||
|
"max_dd_pips": -307.3,
|
||||||
|
"expectancy": 5.59
|
||||||
|
},
|
||||||
|
"2023": {
|
||||||
|
"trades": 38,
|
||||||
|
"wr": 55.3,
|
||||||
|
"pf": 1.07,
|
||||||
|
"pnl_pips": 56.9,
|
||||||
|
"max_dd_pips": -182.9,
|
||||||
|
"expectancy": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"trades": 150,
|
||||||
|
"wr": 50.7,
|
||||||
|
"pf": 1.07,
|
||||||
|
"pnl_pips": 203.1,
|
||||||
|
"max_dd_pips": -533.2,
|
||||||
|
"expectancy": 1.35
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-409
@@ -13,30 +13,12 @@ Supports:
|
|||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.position_manager import (
|
||||||
# ---------------------------------------------------------------------------
|
Position, TradeRecord, PositionManager,
|
||||||
# Spread Configuration (in pips)
|
SPREAD_PIPS, PIP_SIZE,
|
||||||
# ---------------------------------------------------------------------------
|
)
|
||||||
SPREAD_PIPS = {
|
|
||||||
"EUR_USD": 1.5, "GBP_USD": 1.5,
|
|
||||||
"GBP_AUD": 2.0, "EUR_AUD": 2.0,
|
|
||||||
"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
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Major news events (simplified: first Friday of month = NFP, plus key dates)
|
# Major news events (simplified: first Friday of month = NFP, plus key dates)
|
||||||
# In production, use a calendar API. For backtesting 2021-2024, we hardcode
|
# In production, use a calendar API. For backtesting 2021-2024, we hardcode
|
||||||
@@ -46,72 +28,6 @@ MAJOR_NEWS_DAY_OF_WEEK = 4 # Friday
|
|||||||
MAJOR_NEWS_WEEK = 1 # First full week of month
|
MAJOR_NEWS_WEEK = 1 # First full week of month
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Position:
|
|
||||||
"""Represents an open position."""
|
|
||||||
entry_time: pd.Timestamp
|
|
||||||
direction: str # 'LONG' or 'SHORT'
|
|
||||||
entry_price: float
|
|
||||||
sl_price: float
|
|
||||||
tp1_price: float
|
|
||||||
tp2_price: float
|
|
||||||
tp3_price: float
|
|
||||||
initial_size: float
|
|
||||||
current_size: float
|
|
||||||
tp_splits: tuple # e.g. (0.40, 0.40, 0.20)
|
|
||||||
trail_atr_mult: float
|
|
||||||
max_bars: int
|
|
||||||
bars_held: int = 0
|
|
||||||
tp1_hit: bool = False
|
|
||||||
tp2_hit: bool = False
|
|
||||||
tp3_hit: bool = False
|
|
||||||
trailing_sl: Optional[float] = None
|
|
||||||
strategy_id: int = 0
|
|
||||||
confluence_score: int = 0
|
|
||||||
signal_features: dict = field(default_factory=dict)
|
|
||||||
realized_pnl: float = 0.0 # Tracks PnL from partial closes
|
|
||||||
no_breakeven: bool = False # When True, don't move SL to breakeven after TP1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TradeRecord:
|
|
||||||
"""Rich trade log entry for Phase 3 feature engineering."""
|
|
||||||
timestamp: pd.Timestamp = None
|
|
||||||
strategy_id: int = 0
|
|
||||||
pair: str = ""
|
|
||||||
signal_direction: str = ""
|
|
||||||
entry_price: float = 0.0
|
|
||||||
sl_price: float = 0.0
|
|
||||||
tp1_price: float = 0.0
|
|
||||||
tp2_price: float = 0.0
|
|
||||||
tp3_price: float = 0.0
|
|
||||||
lot_size: float = 0.0
|
|
||||||
confluence_score: int = 0
|
|
||||||
session: str = ""
|
|
||||||
spread_at_entry: float = 0.0
|
|
||||||
atr_at_entry: float = 0.0
|
|
||||||
adx_at_entry: float = 0.0
|
|
||||||
rsi_at_entry: float = 0.0
|
|
||||||
ema_50_value: float = 0.0
|
|
||||||
ema_200_value: float = 0.0
|
|
||||||
vwap_deviation: float = 0.0
|
|
||||||
news_within_60min: bool = False
|
|
||||||
macd_hist_at_entry: float = 0.0
|
|
||||||
stoch_k_at_entry: float = 0.0
|
|
||||||
distance_from_ema50_pips: float = 0.0
|
|
||||||
candle_body_ratio: float = 0.0
|
|
||||||
hour_of_day: int = 0
|
|
||||||
day_of_week: int = 0
|
|
||||||
entry_pattern: str = ""
|
|
||||||
exit_price: float = 0.0
|
|
||||||
exit_reason: str = ""
|
|
||||||
exit_time: pd.Timestamp = None
|
|
||||||
pnl_pips: float = 0.0
|
|
||||||
pnl_dollars: float = 0.0
|
|
||||||
hold_time_minutes: int = 0
|
|
||||||
win: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class Backtester:
|
class Backtester:
|
||||||
"""Event-driven backtesting engine."""
|
"""Event-driven backtesting engine."""
|
||||||
|
|
||||||
@@ -129,18 +45,50 @@ class Backtester:
|
|||||||
self.data = data
|
self.data = data
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self.pair = pair
|
self.pair = pair
|
||||||
self.equity = starting_equity
|
|
||||||
self.starting_equity = starting_equity
|
self.starting_equity = starting_equity
|
||||||
self.peak_equity = starting_equity
|
|
||||||
self.htf_data = htf_data
|
self.htf_data = htf_data
|
||||||
|
|
||||||
self.pip = PIP_SIZE.get(pair, 0.0001)
|
# Delegate position management to PositionManager
|
||||||
self.spread = SPREAD_PIPS.get(pair, 2.0) * self.pip
|
self._pm = PositionManager(pair, starting_equity)
|
||||||
|
|
||||||
self.open_positions: list[Position] = []
|
|
||||||
self.closed_trades: list[TradeRecord] = []
|
|
||||||
self.equity_curve = []
|
self.equity_curve = []
|
||||||
self.daily_pnl = {}
|
|
||||||
|
# Proxy properties so existing code that reads these still works
|
||||||
|
@property
|
||||||
|
def equity(self):
|
||||||
|
return self._pm.equity
|
||||||
|
|
||||||
|
@equity.setter
|
||||||
|
def equity(self, value):
|
||||||
|
self._pm.equity = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def peak_equity(self):
|
||||||
|
return self._pm.peak_equity
|
||||||
|
|
||||||
|
@peak_equity.setter
|
||||||
|
def peak_equity(self, value):
|
||||||
|
self._pm.peak_equity = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def open_positions(self):
|
||||||
|
return self._pm.open_positions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def closed_trades(self):
|
||||||
|
return self._pm.closed_trades
|
||||||
|
|
||||||
|
@property
|
||||||
|
def daily_pnl(self):
|
||||||
|
return self._pm.daily_pnl
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pip(self):
|
||||||
|
return self._pm.pip
|
||||||
|
|
||||||
|
@property
|
||||||
|
def spread(self):
|
||||||
|
return self._pm.spread
|
||||||
|
|
||||||
def _get_session(self, hour: int) -> str:
|
def _get_session(self, hour: int) -> str:
|
||||||
if 0 <= hour < 8:
|
if 0 <= hour < 8:
|
||||||
@@ -152,63 +100,16 @@ class Backtester:
|
|||||||
else:
|
else:
|
||||||
return "NY"
|
return "NY"
|
||||||
|
|
||||||
def _get_slippage(self, hour: int) -> float:
|
|
||||||
"""Slippage in price units. High-volume sessions get less slippage."""
|
|
||||||
if 8 <= hour < 17: # London + NY overlap
|
|
||||||
return 0.4 * self.pip
|
|
||||||
else:
|
|
||||||
return 1.0 * self.pip
|
|
||||||
|
|
||||||
def _is_near_news(self, timestamp: pd.Timestamp) -> bool:
|
def _is_near_news(self, timestamp: pd.Timestamp) -> bool:
|
||||||
"""Simple news filter: first Friday of each month (NFP proxy) +/- 30 min."""
|
"""Simple news filter: first Friday of each month (NFP proxy) +/- 30 min."""
|
||||||
# Check if current day is first Friday of month
|
if timestamp.weekday() != 4:
|
||||||
if timestamp.weekday() != 4: # Not Friday
|
|
||||||
return False
|
return False
|
||||||
if timestamp.day > 7: # Not first week
|
if timestamp.day > 7:
|
||||||
return False
|
return False
|
||||||
# NFP typically at 13:30 UTC
|
|
||||||
if 13 <= timestamp.hour <= 14:
|
if 13 <= timestamp.hour <= 14:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _calculate_position_size(self, sl_distance: float,
|
|
||||||
confluence_score: int,
|
|
||||||
risk_pct: float = 0.01) -> float:
|
|
||||||
"""Risk-based position sizing (default 1%, strategies can override)."""
|
|
||||||
if sl_distance <= 0:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
risk_amount = self.equity * risk_pct
|
|
||||||
# Position size = risk_amount / SL distance in price
|
|
||||||
position_size = risk_amount / sl_distance
|
|
||||||
return round(position_size, 2)
|
|
||||||
|
|
||||||
def _get_htf_row(self, timestamp: pd.Timestamp) -> Optional[pd.Series]:
|
|
||||||
"""Get the most recent FULLY CLOSED higher-timeframe candle."""
|
|
||||||
if self.htf_data is None:
|
|
||||||
return None
|
|
||||||
# Only use HTF candles that closed BEFORE current timestamp
|
|
||||||
valid = self.htf_data[self.htf_data.index < timestamp]
|
|
||||||
if len(valid) == 0:
|
|
||||||
return None
|
|
||||||
return valid.iloc[-1]
|
|
||||||
|
|
||||||
def _apply_spread_to_entry(self, price: float, direction: str) -> float:
|
|
||||||
"""Apply spread to entry price."""
|
|
||||||
if direction == "LONG":
|
|
||||||
return price + self.spread # Buy at ask
|
|
||||||
else:
|
|
||||||
return price - self.spread # Sell at bid (lower)
|
|
||||||
|
|
||||||
def _apply_slippage_to_entry(self, price: float, direction: str,
|
|
||||||
hour: int) -> float:
|
|
||||||
"""Apply slippage to entry price."""
|
|
||||||
slip = self._get_slippage(hour)
|
|
||||||
if direction == "LONG":
|
|
||||||
return price + slip # Slippage works against us
|
|
||||||
else:
|
|
||||||
return price - slip
|
|
||||||
|
|
||||||
def _check_daily_drawdown(self, timestamp: pd.Timestamp) -> bool:
|
def _check_daily_drawdown(self, timestamp: pd.Timestamp) -> bool:
|
||||||
"""Check if 5% daily drawdown has been breached."""
|
"""Check if 5% daily drawdown has been breached."""
|
||||||
date_key = timestamp.date()
|
date_key = timestamp.date()
|
||||||
@@ -216,204 +117,14 @@ class Backtester:
|
|||||||
self.daily_pnl[date_key] = 0.0
|
self.daily_pnl[date_key] = 0.0
|
||||||
return self.daily_pnl[date_key] <= -0.05 * self.starting_equity
|
return self.daily_pnl[date_key] <= -0.05 * self.starting_equity
|
||||||
|
|
||||||
def _update_positions(self, candle: pd.Series, i: int):
|
def _get_htf_row(self, timestamp: pd.Timestamp) -> Optional[pd.Series]:
|
||||||
"""Check SL/TP/trailing/time exits for all open positions."""
|
"""Get the most recent FULLY CLOSED higher-timeframe candle."""
|
||||||
to_close = []
|
if self.htf_data is None:
|
||||||
|
return None
|
||||||
for pos in self.open_positions:
|
valid = self.htf_data[self.htf_data.index < timestamp]
|
||||||
pos.bars_held += 1
|
if len(valid) == 0:
|
||||||
high = candle["high"]
|
return None
|
||||||
low = candle["low"]
|
return valid.iloc[-1]
|
||||||
close_price = candle["close"]
|
|
||||||
current_atr = candle.get("atr_14", 0)
|
|
||||||
|
|
||||||
# Determine effective SL
|
|
||||||
effective_sl = pos.trailing_sl if pos.trailing_sl is not None else pos.sl_price
|
|
||||||
|
|
||||||
if pos.direction == "LONG":
|
|
||||||
# Check SL
|
|
||||||
if low <= effective_sl:
|
|
||||||
self._close_position(pos, effective_sl, "SL", candle)
|
|
||||||
to_close.append(pos)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check TP1
|
|
||||||
if not pos.tp1_hit and high >= pos.tp1_price:
|
|
||||||
close_size = pos.initial_size * pos.tp_splits[0]
|
|
||||||
self._partial_close(pos, pos.tp1_price, close_size, "TP1", candle)
|
|
||||||
pos.tp1_hit = True
|
|
||||||
if not pos.no_breakeven:
|
|
||||||
# Move SL to breakeven after TP1
|
|
||||||
pos.trailing_sl = pos.entry_price
|
|
||||||
|
|
||||||
# Check TP2
|
|
||||||
if not pos.tp2_hit and pos.tp1_hit and high >= pos.tp2_price:
|
|
||||||
close_size = pos.initial_size * pos.tp_splits[1]
|
|
||||||
if close_size > 0:
|
|
||||||
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
|
||||||
pos.tp2_hit = True
|
|
||||||
# If no breakeven was set, start trailing from original SL
|
|
||||||
if pos.trailing_sl is None:
|
|
||||||
pos.trailing_sl = pos.sl_price
|
|
||||||
|
|
||||||
# Check TP3
|
|
||||||
if not pos.tp3_hit and pos.tp2_hit and high >= pos.tp3_price:
|
|
||||||
self._close_position(pos, pos.tp3_price, "TP3", candle)
|
|
||||||
to_close.append(pos)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Trailing stop for runner (after TP2)
|
|
||||||
if pos.tp2_hit and current_atr > 0:
|
|
||||||
new_trail = high - pos.trail_atr_mult * current_atr
|
|
||||||
if pos.trailing_sl is None or new_trail > pos.trailing_sl:
|
|
||||||
pos.trailing_sl = new_trail
|
|
||||||
|
|
||||||
else: # SHORT
|
|
||||||
# Check SL
|
|
||||||
if high >= effective_sl:
|
|
||||||
self._close_position(pos, effective_sl, "SL", candle)
|
|
||||||
to_close.append(pos)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check TP1
|
|
||||||
if not pos.tp1_hit and low <= pos.tp1_price:
|
|
||||||
close_size = pos.initial_size * pos.tp_splits[0]
|
|
||||||
self._partial_close(pos, pos.tp1_price, close_size, "TP1", candle)
|
|
||||||
pos.tp1_hit = True
|
|
||||||
if not pos.no_breakeven:
|
|
||||||
pos.trailing_sl = pos.entry_price
|
|
||||||
|
|
||||||
# Check TP2
|
|
||||||
if not pos.tp2_hit and pos.tp1_hit and low <= pos.tp2_price:
|
|
||||||
close_size = pos.initial_size * pos.tp_splits[1]
|
|
||||||
if close_size > 0:
|
|
||||||
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
|
||||||
pos.tp2_hit = True
|
|
||||||
if pos.trailing_sl is None:
|
|
||||||
pos.trailing_sl = pos.sl_price
|
|
||||||
|
|
||||||
# Check TP3
|
|
||||||
if not pos.tp3_hit and pos.tp2_hit and low <= pos.tp3_price:
|
|
||||||
self._close_position(pos, pos.tp3_price, "TP3", candle)
|
|
||||||
to_close.append(pos)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Trailing stop for runner
|
|
||||||
if pos.tp2_hit and current_atr > 0:
|
|
||||||
new_trail = low + pos.trail_atr_mult * current_atr
|
|
||||||
if pos.trailing_sl is None or new_trail < pos.trailing_sl:
|
|
||||||
pos.trailing_sl = new_trail
|
|
||||||
|
|
||||||
# Time-based exit
|
|
||||||
if pos.max_bars > 0 and pos.bars_held >= pos.max_bars:
|
|
||||||
self._close_position(pos, close_price, "TIME", candle)
|
|
||||||
to_close.append(pos)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for pos in to_close:
|
|
||||||
if pos in self.open_positions:
|
|
||||||
self.open_positions.remove(pos)
|
|
||||||
|
|
||||||
def _partial_close(self, pos: Position, exit_price: float,
|
|
||||||
close_size: float, reason: str, candle: pd.Series):
|
|
||||||
"""Close a partial portion of a position."""
|
|
||||||
if pos.direction == "LONG":
|
|
||||||
pnl_per_unit = exit_price - pos.entry_price
|
|
||||||
else:
|
|
||||||
pnl_per_unit = pos.entry_price - exit_price
|
|
||||||
|
|
||||||
pnl = pnl_per_unit * close_size
|
|
||||||
self.equity += pnl
|
|
||||||
pos.current_size -= close_size
|
|
||||||
pos.realized_pnl += pnl
|
|
||||||
|
|
||||||
date_key = candle.name.date() if hasattr(candle.name, 'date') else None
|
|
||||||
if date_key:
|
|
||||||
self.daily_pnl[date_key] = self.daily_pnl.get(date_key, 0.0) + pnl
|
|
||||||
|
|
||||||
if self.equity > self.peak_equity:
|
|
||||||
self.peak_equity = self.equity
|
|
||||||
|
|
||||||
def _close_position(self, pos: Position, exit_price: float,
|
|
||||||
reason: str, candle: pd.Series):
|
|
||||||
"""Fully close remaining position and log the trade."""
|
|
||||||
remaining = pos.current_size
|
|
||||||
if remaining <= 0:
|
|
||||||
remaining = 0.01 # avoid zero
|
|
||||||
|
|
||||||
if pos.direction == "LONG":
|
|
||||||
pnl_per_unit = exit_price - pos.entry_price
|
|
||||||
else:
|
|
||||||
pnl_per_unit = pos.entry_price - exit_price
|
|
||||||
|
|
||||||
final_pnl = pnl_per_unit * remaining
|
|
||||||
self.equity += final_pnl
|
|
||||||
|
|
||||||
# Total PnL = partial closes + final close
|
|
||||||
total_pnl = pos.realized_pnl + final_pnl
|
|
||||||
total_pnl_pips = total_pnl / (pos.initial_size * self.pip) if pos.initial_size > 0 else 0
|
|
||||||
|
|
||||||
date_key = candle.name.date() if hasattr(candle.name, 'date') else None
|
|
||||||
if date_key:
|
|
||||||
self.daily_pnl[date_key] = self.daily_pnl.get(date_key, 0.0) + final_pnl
|
|
||||||
|
|
||||||
if self.equity > self.peak_equity:
|
|
||||||
self.peak_equity = self.equity
|
|
||||||
|
|
||||||
exit_time = candle.name
|
|
||||||
hold_minutes = 0
|
|
||||||
if hasattr(exit_time, 'timestamp') and hasattr(pos.entry_time, 'timestamp'):
|
|
||||||
hold_minutes = int((exit_time - pos.entry_time).total_seconds() / 60)
|
|
||||||
|
|
||||||
# Build detailed exit reason (e.g. "TP1+TP2+SL" instead of just "SL")
|
|
||||||
exit_detail = reason
|
|
||||||
if reason != "TP3":
|
|
||||||
parts = []
|
|
||||||
if pos.tp1_hit:
|
|
||||||
parts.append("TP1")
|
|
||||||
if pos.tp2_hit:
|
|
||||||
parts.append("TP2")
|
|
||||||
parts.append(reason)
|
|
||||||
exit_detail = "+".join(parts)
|
|
||||||
|
|
||||||
features = pos.signal_features
|
|
||||||
record = TradeRecord(
|
|
||||||
timestamp=pos.entry_time,
|
|
||||||
strategy_id=pos.strategy_id,
|
|
||||||
pair=self.pair,
|
|
||||||
signal_direction=pos.direction,
|
|
||||||
entry_price=pos.entry_price,
|
|
||||||
sl_price=pos.sl_price,
|
|
||||||
tp1_price=pos.tp1_price,
|
|
||||||
tp2_price=pos.tp2_price,
|
|
||||||
tp3_price=pos.tp3_price,
|
|
||||||
lot_size=pos.initial_size,
|
|
||||||
confluence_score=pos.confluence_score,
|
|
||||||
session=features.get("session", ""),
|
|
||||||
spread_at_entry=features.get("spread_at_entry", 0),
|
|
||||||
atr_at_entry=features.get("atr_at_entry", 0),
|
|
||||||
adx_at_entry=features.get("adx_at_entry", 0),
|
|
||||||
rsi_at_entry=features.get("rsi_at_entry", 0),
|
|
||||||
ema_50_value=features.get("ema_50_value", 0),
|
|
||||||
ema_200_value=features.get("ema_200_value", 0),
|
|
||||||
vwap_deviation=features.get("vwap_deviation", 0),
|
|
||||||
news_within_60min=features.get("news_within_60min", False),
|
|
||||||
macd_hist_at_entry=features.get("macd_hist_at_entry", 0),
|
|
||||||
stoch_k_at_entry=features.get("stoch_k_at_entry", 0),
|
|
||||||
distance_from_ema50_pips=features.get("distance_from_ema50_pips", 0),
|
|
||||||
candle_body_ratio=features.get("candle_body_ratio", 0),
|
|
||||||
hour_of_day=features.get("hour_of_day", 0),
|
|
||||||
day_of_week=features.get("day_of_week", 0),
|
|
||||||
entry_pattern=features.get("entry_pattern", ""),
|
|
||||||
exit_price=exit_price,
|
|
||||||
exit_reason=exit_detail,
|
|
||||||
exit_time=exit_time,
|
|
||||||
pnl_pips=total_pnl_pips,
|
|
||||||
pnl_dollars=total_pnl,
|
|
||||||
hold_time_minutes=hold_minutes,
|
|
||||||
win=total_pnl > 0,
|
|
||||||
)
|
|
||||||
self.closed_trades.append(record)
|
|
||||||
|
|
||||||
def _build_signal_features(self, candle: pd.Series, i: int) -> dict:
|
def _build_signal_features(self, candle: pd.Series, i: int) -> dict:
|
||||||
"""Extract features from current candle for trade logging."""
|
"""Extract features from current candle for trade logging."""
|
||||||
@@ -447,18 +158,15 @@ class Backtester:
|
|||||||
|
|
||||||
def run(self) -> dict:
|
def run(self) -> dict:
|
||||||
"""Run the backtest. Returns performance report dict."""
|
"""Run the backtest. Returns performance report dict."""
|
||||||
# Give the strategy access to full HTF data (strategy filters by timestamp)
|
|
||||||
self.strategy.htf_data = self.htf_data
|
self.strategy.htf_data = self.htf_data
|
||||||
|
|
||||||
# Need at least 200 bars for indicators to warm up
|
|
||||||
warmup = 200
|
warmup = 200
|
||||||
|
|
||||||
for i in range(warmup, len(self.data)):
|
for i in range(warmup, len(self.data)):
|
||||||
candle = self.data.iloc[i]
|
candle = self.data.iloc[i]
|
||||||
timestamp = self.data.index[i]
|
timestamp = self.data.index[i]
|
||||||
|
|
||||||
# Update open positions first (SL/TP/trail/time checks)
|
# Update open positions (SL/TP/trail/time checks)
|
||||||
self._update_positions(candle, i)
|
self._pm.update_positions(candle)
|
||||||
|
|
||||||
# Record equity
|
# Record equity
|
||||||
self.equity_curve.append({
|
self.equity_curve.append({
|
||||||
@@ -475,83 +183,38 @@ class Backtester:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if already have an open position (1 at a time per strategy)
|
# Skip if already have an open position (1 at a time per strategy)
|
||||||
if self.open_positions:
|
if self._pm.open_positions:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get HTF context (only fully closed candles)
|
# Get HTF context
|
||||||
htf_row = self._get_htf_row(timestamp)
|
htf_row = self._get_htf_row(timestamp)
|
||||||
|
|
||||||
# Pass full data + current index. Strategy must only access [:i+1].
|
# Check for signal
|
||||||
signal = self.strategy.check_signal(self.data, i, candle, htf_row)
|
signal = self.strategy.check_signal(self.data, i, candle, htf_row)
|
||||||
|
|
||||||
if signal is None:
|
if signal is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
direction = signal["direction"]
|
|
||||||
sl = signal["sl"]
|
|
||||||
tp1 = signal["tp1"]
|
|
||||||
tp2 = signal["tp2"]
|
|
||||||
tp3 = signal["tp3"]
|
|
||||||
confluence = signal.get("confluence", 0)
|
|
||||||
tp_splits = signal.get("tp_splits", (0.40, 0.40, 0.20))
|
|
||||||
trail_mult = signal.get("trail_atr_mult", 1.5)
|
|
||||||
max_bars = signal.get("max_bars", 200)
|
|
||||||
no_breakeven = signal.get("no_breakeven", False)
|
|
||||||
risk_pct = signal.get("risk_pct", 0.01)
|
|
||||||
|
|
||||||
# Minimum RR check (TP1 vs SL distance)
|
|
||||||
entry = candle["close"]
|
|
||||||
sl_dist = abs(entry - sl)
|
|
||||||
tp1_dist = abs(tp1 - entry)
|
|
||||||
if sl_dist == 0 or tp1_dist / sl_dist < 0.5:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Apply spread and slippage to entry
|
|
||||||
hour = timestamp.hour if hasattr(timestamp, 'hour') else 0
|
|
||||||
adj_entry = self._apply_spread_to_entry(entry, direction)
|
|
||||||
adj_entry = self._apply_slippage_to_entry(adj_entry, direction, hour)
|
|
||||||
|
|
||||||
# Recalculate SL distance after adjustment
|
|
||||||
sl_dist_adj = abs(adj_entry - sl)
|
|
||||||
if sl_dist_adj <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Position sizing
|
|
||||||
size = self._calculate_position_size(sl_dist_adj, confluence, risk_pct)
|
|
||||||
if size <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Build features for logging
|
# Build features for logging
|
||||||
features = self._build_signal_features(candle, i)
|
features = self._build_signal_features(candle, i)
|
||||||
features["entry_pattern"] = signal.get("entry_pattern", "")
|
features["entry_pattern"] = signal.get("entry_pattern", "")
|
||||||
|
|
||||||
# Open position
|
risk_pct = signal.get("risk_pct", 0.01)
|
||||||
pos = Position(
|
|
||||||
entry_time=timestamp,
|
# Open position via PositionManager
|
||||||
direction=direction,
|
self._pm.open_position(
|
||||||
entry_price=adj_entry,
|
signal=signal,
|
||||||
sl_price=sl,
|
entry_price=candle["close"],
|
||||||
tp1_price=tp1,
|
timestamp=timestamp,
|
||||||
tp2_price=tp2,
|
atr=candle.get("atr_14", 0),
|
||||||
tp3_price=tp3,
|
|
||||||
initial_size=size,
|
|
||||||
current_size=size,
|
|
||||||
tp_splits=tp_splits,
|
|
||||||
trail_atr_mult=trail_mult,
|
|
||||||
max_bars=max_bars,
|
|
||||||
strategy_id=self.strategy.strategy_id,
|
strategy_id=self.strategy.strategy_id,
|
||||||
confluence_score=confluence,
|
|
||||||
signal_features=features,
|
signal_features=features,
|
||||||
no_breakeven=no_breakeven,
|
risk_pct=risk_pct,
|
||||||
)
|
)
|
||||||
self.open_positions.append(pos)
|
|
||||||
|
|
||||||
# Force-close any remaining positions at last candle
|
# Force-close any remaining positions at last candle
|
||||||
if self.open_positions:
|
if self._pm.open_positions:
|
||||||
last_candle = self.data.iloc[-1]
|
last_candle = self.data.iloc[-1]
|
||||||
for pos in list(self.open_positions):
|
self._pm.force_close_all(last_candle)
|
||||||
self._close_position(pos, last_candle["close"], "END", last_candle)
|
|
||||||
self.open_positions.clear()
|
|
||||||
|
|
||||||
return self.generate_report()
|
return self.generate_report()
|
||||||
|
|
||||||
@@ -596,7 +259,7 @@ class Backtester:
|
|||||||
if len(eq) > 0:
|
if len(eq) > 0:
|
||||||
peak = eq.cummax()
|
peak = eq.cummax()
|
||||||
dd = (eq - peak) / peak
|
dd = (eq - peak) / peak
|
||||||
max_dd = dd.min() * 100 # negative percentage
|
max_dd = dd.min() * 100
|
||||||
else:
|
else:
|
||||||
max_dd = 0
|
max_dd = 0
|
||||||
|
|
||||||
@@ -615,10 +278,8 @@ class Backtester:
|
|||||||
if not r and current_streak > max_consec_losses:
|
if not r and current_streak > max_consec_losses:
|
||||||
max_consec_losses = current_streak
|
max_consec_losses = current_streak
|
||||||
|
|
||||||
# Average hold time
|
|
||||||
avg_hold = np.mean([t.hold_time_minutes for t in trades])
|
avg_hold = np.mean([t.hold_time_minutes for t in trades])
|
||||||
|
|
||||||
# Best/worst trade
|
|
||||||
best_trade_pips = max(pnl_pips)
|
best_trade_pips = max(pnl_pips)
|
||||||
worst_trade_pips = min(pnl_pips)
|
worst_trade_pips = min(pnl_pips)
|
||||||
best_trade_dollars = max(pnls)
|
best_trade_dollars = max(pnls)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
OANDA candle polling + indicator computation for the live engine.
|
||||||
|
|
||||||
|
Fetches recent candles from OANDA REST API, computes technical indicators,
|
||||||
|
and exposes them for strategy signal checks.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.indicators.technical import compute_all_indicators
|
||||||
|
|
||||||
|
# Load OANDA credentials from config/.env
|
||||||
|
ROOT = Path(__file__).resolve().parent.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")
|
||||||
|
OANDA_ENV = os.getenv("OANDA_ENV", "practice")
|
||||||
|
BASE_URL = (
|
||||||
|
"https://api-fxpractice.oanda.com"
|
||||||
|
if OANDA_ENV == "practice"
|
||||||
|
else "https://api-fxtrade.oanda.com"
|
||||||
|
)
|
||||||
|
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_candles(instrument: str, granularity: str, count: int) -> list[dict]:
|
||||||
|
"""Fetch the most recent `count` candles from OANDA."""
|
||||||
|
url = f"{BASE_URL}/v3/instruments/{instrument}/candles"
|
||||||
|
params = {
|
||||||
|
"granularity": granularity,
|
||||||
|
"count": count,
|
||||||
|
"price": "M", # mid prices
|
||||||
|
}
|
||||||
|
r = requests.get(url, headers=HEADERS, params=params, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("candles", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _candles_to_df(candles: list[dict]) -> pd.DataFrame:
|
||||||
|
"""Convert OANDA candle JSON to a DataFrame matching our data format."""
|
||||||
|
rows = []
|
||||||
|
for c in candles:
|
||||||
|
if not c.get("complete", False):
|
||||||
|
continue # skip incomplete candles for indicator calculation
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"])
|
||||||
|
df = df.drop_duplicates(subset=["timestamp"], keep="last")
|
||||||
|
df = df.set_index("timestamp").sort_index()
|
||||||
|
df.index.name = "timestamp"
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class OandaDataFeed:
|
||||||
|
"""Polls OANDA for latest candles and computes indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pair: OANDA instrument name (e.g. "GBP_JPY").
|
||||||
|
granularity: Candle granularity (e.g. "H1", "M15").
|
||||||
|
candle_count: How many candles to fetch (enough for indicator warmup).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pair: str, granularity: str = "H1",
|
||||||
|
candle_count: int = 250):
|
||||||
|
self.pair = pair
|
||||||
|
self.granularity = granularity
|
||||||
|
self.candle_count = candle_count
|
||||||
|
self._cached_df: pd.DataFrame = pd.DataFrame()
|
||||||
|
self._last_fetch_time: float = 0
|
||||||
|
|
||||||
|
def fetch_latest(self) -> pd.DataFrame:
|
||||||
|
"""Fetch latest candles and compute all indicators.
|
||||||
|
|
||||||
|
Returns DataFrame with OHLCV + all indicator columns.
|
||||||
|
Only includes fully closed candles.
|
||||||
|
"""
|
||||||
|
candles = _fetch_candles(self.pair, self.granularity, self.candle_count)
|
||||||
|
df = _candles_to_df(candles)
|
||||||
|
if df.empty:
|
||||||
|
return self._cached_df
|
||||||
|
|
||||||
|
df = compute_all_indicators(df)
|
||||||
|
self._cached_df = df
|
||||||
|
self._last_fetch_time = time.time()
|
||||||
|
return df
|
||||||
|
|
||||||
|
def get_current_candle(self) -> pd.Series:
|
||||||
|
"""Return the most recent fully closed candle with indicators."""
|
||||||
|
if self._cached_df.empty:
|
||||||
|
self.fetch_latest()
|
||||||
|
if self._cached_df.empty:
|
||||||
|
return pd.Series()
|
||||||
|
return self._cached_df.iloc[-1]
|
||||||
|
|
||||||
|
def get_htf_row(self, timestamp=None) -> pd.Series:
|
||||||
|
"""Get the most recent fully closed candle as HTF context.
|
||||||
|
|
||||||
|
For H1 strategies where HTF=H1 (self-reference), this is just
|
||||||
|
the latest closed candle. For M15 strategies needing H1 context,
|
||||||
|
a separate OandaDataFeed for H1 should be used.
|
||||||
|
"""
|
||||||
|
if self._cached_df.empty:
|
||||||
|
return pd.Series()
|
||||||
|
if timestamp is not None:
|
||||||
|
valid = self._cached_df[self._cached_df.index < timestamp]
|
||||||
|
if valid.empty:
|
||||||
|
return pd.Series()
|
||||||
|
return valid.iloc[-1]
|
||||||
|
return self._cached_df.iloc[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_stale(self) -> bool:
|
||||||
|
"""True if data hasn't been fetched recently."""
|
||||||
|
if self._last_fetch_time == 0:
|
||||||
|
return True
|
||||||
|
# H1 = stale after 65 min, M15 = stale after 20 min
|
||||||
|
gran_minutes = {"H1": 65, "M15": 20, "M5": 8}
|
||||||
|
stale_seconds = gran_minutes.get(self.granularity, 65) * 60
|
||||||
|
return (time.time() - self._last_fetch_time) > stale_seconds
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
Live trading engine orchestrator.
|
||||||
|
|
||||||
|
Ties together data feeds, strategies, position managers, and the executor
|
||||||
|
into a single run loop that polls OANDA and manages positions.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.position_manager import PositionManager, PIP_SIZE, SPREAD_PIPS
|
||||||
|
from src.live.data_feed import OandaDataFeed
|
||||||
|
from src.live.executor import OandaExecutor
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
LOGS_DIR = ROOT / "logs"
|
||||||
|
LOGS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StrategySlot:
|
||||||
|
"""One strategy-pair combination running in the live engine."""
|
||||||
|
name: str
|
||||||
|
strategy: object # BaseStrategy instance
|
||||||
|
pair: str
|
||||||
|
timeframe: str
|
||||||
|
data_feed: OandaDataFeed
|
||||||
|
position_manager: PositionManager
|
||||||
|
htf_feed: Optional[OandaDataFeed] = None # for multi-TF strategies
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class LiveEngine:
|
||||||
|
"""Main orchestrator for live paper/live trading.
|
||||||
|
|
||||||
|
Each cycle:
|
||||||
|
1. Check kill switch
|
||||||
|
2. Fetch latest candles for each slot
|
||||||
|
3. Compute indicators
|
||||||
|
4. Check for new signals (only if no open position)
|
||||||
|
5. Update open positions (SL/TP/trail/time)
|
||||||
|
6. Log state
|
||||||
|
7. Print status summary
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, slots: list[StrategySlot], executor: OandaExecutor,
|
||||||
|
interval_seconds: int = 60, max_daily_drawdown_pct: float = 5.0):
|
||||||
|
self.slots = slots
|
||||||
|
self.executor = executor
|
||||||
|
self.interval_seconds = interval_seconds
|
||||||
|
self.max_daily_drawdown_pct = max_daily_drawdown_pct
|
||||||
|
self._state_path = LOGS_DIR / "live_state.json"
|
||||||
|
self._trades_path = LOGS_DIR / "live_trades.csv"
|
||||||
|
self._cycle_count = 0
|
||||||
|
|
||||||
|
def run_once(self):
|
||||||
|
"""Single iteration: fetch, check signals, manage positions."""
|
||||||
|
self._cycle_count += 1
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"Cycle #{self._cycle_count} | {now.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
# 1. Kill switch
|
||||||
|
if self.executor.check_kill_switch():
|
||||||
|
print("KILL SWITCH ACTIVE — skipping all trading.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for slot in self.slots:
|
||||||
|
if not slot.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
slot_label = f"[{slot.name}/{slot.pair}]"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 2. Fetch latest candles
|
||||||
|
df = slot.data_feed.fetch_latest()
|
||||||
|
if df.empty:
|
||||||
|
print(f" {slot_label} No data fetched, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
htf_df = None
|
||||||
|
if slot.htf_feed is not None:
|
||||||
|
htf_df = slot.htf_feed.fetch_latest()
|
||||||
|
|
||||||
|
# 3. Current candle + HTF row
|
||||||
|
current = df.iloc[-1]
|
||||||
|
idx = len(df) - 1
|
||||||
|
timestamp = df.index[-1]
|
||||||
|
|
||||||
|
htf_row = None
|
||||||
|
if htf_df is not None and not htf_df.empty:
|
||||||
|
valid = htf_df[htf_df.index < timestamp]
|
||||||
|
if not valid.empty:
|
||||||
|
htf_row = valid.iloc[-1]
|
||||||
|
elif slot.timeframe == "H1":
|
||||||
|
# H1 self-reference for HTF
|
||||||
|
valid = df[df.index < timestamp]
|
||||||
|
if not valid.empty:
|
||||||
|
htf_row = valid.iloc[-1]
|
||||||
|
|
||||||
|
# 4. Update existing positions
|
||||||
|
pm = slot.position_manager
|
||||||
|
closed = pm.update_positions(current)
|
||||||
|
for trade in closed:
|
||||||
|
self._log_closed_trade(trade, slot.name)
|
||||||
|
print(f" {slot_label} CLOSED: {trade.signal_direction} "
|
||||||
|
f"exit={trade.exit_reason} pnl={trade.pnl_pips:+.1f}p")
|
||||||
|
|
||||||
|
# 5. Check for new signal (only if no open position)
|
||||||
|
if not pm.get_open_positions():
|
||||||
|
# Daily drawdown check
|
||||||
|
today = now.date()
|
||||||
|
daily_pnl = pm.daily_pnl.get(today, 0.0)
|
||||||
|
dd_limit = pm.starting_equity * self.max_daily_drawdown_pct / 100
|
||||||
|
if daily_pnl <= -dd_limit:
|
||||||
|
print(f" {slot_label} Daily DD limit hit ({daily_pnl:.0f}), skipping signals.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Give strategy access to HTF data
|
||||||
|
if htf_df is not None:
|
||||||
|
slot.strategy.htf_data = htf_df
|
||||||
|
|
||||||
|
signal = slot.strategy.check_signal(df, idx, current, htf_row)
|
||||||
|
|
||||||
|
if signal is not None:
|
||||||
|
atr = current.get("atr_14", 0)
|
||||||
|
pos = pm.open_position(
|
||||||
|
signal=signal,
|
||||||
|
entry_price=current["close"],
|
||||||
|
timestamp=timestamp,
|
||||||
|
atr=atr,
|
||||||
|
strategy_id=slot.strategy.strategy_id,
|
||||||
|
signal_features={"entry_pattern": signal.get("entry_pattern", "")},
|
||||||
|
)
|
||||||
|
if pos is not None:
|
||||||
|
print(f" {slot_label} NEW {pos.direction} @ {pos.entry_price:.5f} "
|
||||||
|
f"SL={pos.sl_price:.5f} TP1={pos.tp1_price:.5f}")
|
||||||
|
|
||||||
|
# Log to executor
|
||||||
|
self.executor.place_market_order(
|
||||||
|
pair=slot.pair,
|
||||||
|
units=int(pos.initial_size),
|
||||||
|
direction=pos.direction,
|
||||||
|
sl=pos.sl_price,
|
||||||
|
tp=pos.tp1_price,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" {slot_label} No signal.")
|
||||||
|
else:
|
||||||
|
open_pos = pm.get_open_positions()[0]
|
||||||
|
print(f" {slot_label} Open: {open_pos.direction} "
|
||||||
|
f"bars={open_pos.bars_held} "
|
||||||
|
f"tp1={'Y' if open_pos.tp1_hit else 'N'} "
|
||||||
|
f"tp2={'Y' if open_pos.tp2_hit else 'N'}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {slot_label} ERROR: {e}")
|
||||||
|
|
||||||
|
# 6. Save state
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
|
# 7. Summary
|
||||||
|
self._print_summary()
|
||||||
|
|
||||||
|
def run_loop(self):
|
||||||
|
"""Continuous loop with interval sleep."""
|
||||||
|
print(f"Starting live engine loop (interval={self.interval_seconds}s)")
|
||||||
|
print(f"Slots: {len(self.slots)}")
|
||||||
|
for s in self.slots:
|
||||||
|
print(f" - {s.name} / {s.pair} / {s.timeframe}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.run_once()
|
||||||
|
print(f"\nSleeping {self.interval_seconds}s...")
|
||||||
|
time.sleep(self.interval_seconds)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down gracefully.")
|
||||||
|
break
|
||||||
|
|
||||||
|
def save_state(self):
|
||||||
|
"""Persist current state to JSON for crash recovery."""
|
||||||
|
state = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"cycle": self._cycle_count,
|
||||||
|
"slots": [],
|
||||||
|
}
|
||||||
|
for slot in self.slots:
|
||||||
|
pm = slot.position_manager
|
||||||
|
slot_state = {
|
||||||
|
"name": slot.name,
|
||||||
|
"pair": slot.pair,
|
||||||
|
"equity": pm.get_equity(),
|
||||||
|
"open_positions": len(pm.get_open_positions()),
|
||||||
|
"closed_trades": len(pm.closed_trades),
|
||||||
|
"enabled": slot.enabled,
|
||||||
|
}
|
||||||
|
if pm.get_open_positions():
|
||||||
|
pos = pm.get_open_positions()[0]
|
||||||
|
slot_state["position"] = {
|
||||||
|
"direction": pos.direction,
|
||||||
|
"entry_price": pos.entry_price,
|
||||||
|
"sl": pos.sl_price,
|
||||||
|
"bars_held": pos.bars_held,
|
||||||
|
"tp1_hit": pos.tp1_hit,
|
||||||
|
"tp2_hit": pos.tp2_hit,
|
||||||
|
}
|
||||||
|
state["slots"].append(slot_state)
|
||||||
|
|
||||||
|
with open(self._state_path, "w") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
|
||||||
|
def load_state(self):
|
||||||
|
"""Restore state after restart (basic — restores equity only)."""
|
||||||
|
if not self._state_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(self._state_path) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
for slot_state in state.get("slots", []):
|
||||||
|
for slot in self.slots:
|
||||||
|
if slot.name == slot_state["name"] and slot.pair == slot_state["pair"]:
|
||||||
|
slot.position_manager.equity = slot_state.get("equity",
|
||||||
|
slot.position_manager.equity)
|
||||||
|
print(f"Loaded state from {self._state_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not load state: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _log_closed_trade(self, trade, slot_name: str):
|
||||||
|
"""Append a closed trade record to live_trades.csv."""
|
||||||
|
columns = [
|
||||||
|
"slot", "timestamp", "pair", "direction", "entry_price",
|
||||||
|
"exit_price", "exit_reason", "pnl_pips", "pnl_dollars",
|
||||||
|
"hold_minutes",
|
||||||
|
]
|
||||||
|
row = {
|
||||||
|
"slot": slot_name,
|
||||||
|
"timestamp": str(trade.timestamp),
|
||||||
|
"pair": trade.pair,
|
||||||
|
"direction": trade.signal_direction,
|
||||||
|
"entry_price": trade.entry_price,
|
||||||
|
"exit_price": trade.exit_price,
|
||||||
|
"exit_reason": trade.exit_reason,
|
||||||
|
"pnl_pips": round(trade.pnl_pips, 2),
|
||||||
|
"pnl_dollars": round(trade.pnl_dollars, 2),
|
||||||
|
"hold_minutes": trade.hold_time_minutes,
|
||||||
|
}
|
||||||
|
import csv
|
||||||
|
file_exists = self._trades_path.exists()
|
||||||
|
with open(self._trades_path, "a", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=columns)
|
||||||
|
if not file_exists:
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
def _print_summary(self):
|
||||||
|
"""Print a compact status summary."""
|
||||||
|
print(f"\n{'─'*50}")
|
||||||
|
print(f"{'Slot':<18} {'Equity':>10} {'Open':>5} {'Trades':>7}")
|
||||||
|
print(f"{'─'*50}")
|
||||||
|
for slot in self.slots:
|
||||||
|
if not slot.enabled:
|
||||||
|
continue
|
||||||
|
pm = slot.position_manager
|
||||||
|
label = f"{slot.name}/{slot.pair}"
|
||||||
|
print(f"{label:<18} {pm.get_equity():>10,.0f} "
|
||||||
|
f"{len(pm.get_open_positions()):>5} "
|
||||||
|
f"{len(pm.closed_trades):>7}")
|
||||||
|
print(f"{'─'*50}")
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Paper/live order execution for the live engine.
|
||||||
|
|
||||||
|
Thin wrapper around OANDA v20 order API. Reuses auth patterns from
|
||||||
|
src/order_executor.py but stripped of Supabase/old strategy coupling.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Project root
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
_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())
|
||||||
|
|
||||||
|
|
||||||
|
def _oanda_base_url() -> str:
|
||||||
|
env = os.getenv("OANDA_ENV", "practice").strip()
|
||||||
|
if env == "practice":
|
||||||
|
return "https://api-fxpractice.oanda.com"
|
||||||
|
return "https://api-fxtrade.oanda.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _oanda_headers() -> dict:
|
||||||
|
api_key = os.getenv("OANDA_API_KEY")
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OandaExecutor:
|
||||||
|
"""Executes orders in paper or live mode.
|
||||||
|
|
||||||
|
Paper mode: logs trades to CSV, tracks positions locally.
|
||||||
|
Live mode: POSTs to OANDA v20 REST API with SL/TP attached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, paper_mode: bool = True):
|
||||||
|
self.paper_mode = paper_mode
|
||||||
|
self._logs_dir = ROOT / "logs"
|
||||||
|
self._logs_dir.mkdir(exist_ok=True)
|
||||||
|
self._csv_path = self._logs_dir / "paper_trades.csv"
|
||||||
|
self._account_id = os.getenv("OANDA_ACCOUNT_ID", "")
|
||||||
|
|
||||||
|
def check_kill_switch(self) -> bool:
|
||||||
|
"""Check for STOP_ALL_TRADING file in project root."""
|
||||||
|
return (ROOT / "STOP_ALL_TRADING").exists()
|
||||||
|
|
||||||
|
def place_market_order(self, pair: str, units: int, direction: str,
|
||||||
|
sl: float = None, tp: float = None) -> dict:
|
||||||
|
"""Place a market order with optional SL/TP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pair: Instrument (e.g. "GBP_JPY").
|
||||||
|
units: Position size in units.
|
||||||
|
direction: "LONG" or "SHORT".
|
||||||
|
sl: Stop loss price.
|
||||||
|
tp: Take profit price.
|
||||||
|
|
||||||
|
Returns dict with status and details.
|
||||||
|
"""
|
||||||
|
if self.check_kill_switch():
|
||||||
|
self._log_trade(pair, direction, units, 0, "BLOCKED_KILL_SWITCH", sl, tp)
|
||||||
|
return {"status": "BLOCKED_KILL_SWITCH"}
|
||||||
|
|
||||||
|
signed_units = units if direction == "LONG" else -units
|
||||||
|
|
||||||
|
if self.paper_mode:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._log_trade(pair, direction, units, 0, "FILLED_PAPER", sl, tp)
|
||||||
|
return {
|
||||||
|
"status": "FILLED",
|
||||||
|
"mode": "paper",
|
||||||
|
"units": signed_units,
|
||||||
|
"sl": sl,
|
||||||
|
"tp": tp,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Live mode
|
||||||
|
return self._place_live_order(pair, signed_units, sl, tp)
|
||||||
|
|
||||||
|
def modify_trade(self, trade_id: str, sl: float = None,
|
||||||
|
tp: float = None) -> dict:
|
||||||
|
"""Modify SL/TP on an existing OANDA trade."""
|
||||||
|
if self.paper_mode:
|
||||||
|
return {"status": "MODIFIED_PAPER", "trade_id": trade_id}
|
||||||
|
|
||||||
|
base = _oanda_base_url()
|
||||||
|
url = f"{base}/v3/accounts/{self._account_id}/trades/{trade_id}/orders"
|
||||||
|
body = {}
|
||||||
|
if sl is not None:
|
||||||
|
body["stopLoss"] = {"price": f"{sl:.5f}", "timeInForce": "GTC"}
|
||||||
|
if tp is not None:
|
||||||
|
body["takeProfit"] = {"price": f"{tp:.5f}", "timeInForce": "GTC"}
|
||||||
|
|
||||||
|
r = requests.put(url, headers=_oanda_headers(), json=body, timeout=10)
|
||||||
|
return {"status": "OK" if r.status_code == 200 else f"ERROR_{r.status_code}",
|
||||||
|
"response": r.json()}
|
||||||
|
|
||||||
|
def close_trade(self, trade_id: str) -> dict:
|
||||||
|
"""Close a specific OANDA trade."""
|
||||||
|
if self.paper_mode:
|
||||||
|
return {"status": "CLOSED_PAPER", "trade_id": trade_id}
|
||||||
|
|
||||||
|
base = _oanda_base_url()
|
||||||
|
url = f"{base}/v3/accounts/{self._account_id}/trades/{trade_id}/close"
|
||||||
|
r = requests.put(url, headers=_oanda_headers(), timeout=10)
|
||||||
|
return {"status": "OK" if r.status_code == 200 else f"ERROR_{r.status_code}",
|
||||||
|
"response": r.json()}
|
||||||
|
|
||||||
|
def get_open_trades(self) -> list:
|
||||||
|
"""Get all open trades from OANDA."""
|
||||||
|
if self.paper_mode:
|
||||||
|
return [] # paper trades tracked by PositionManager
|
||||||
|
|
||||||
|
base = _oanda_base_url()
|
||||||
|
url = f"{base}/v3/accounts/{self._account_id}/openTrades"
|
||||||
|
r = requests.get(url, headers=_oanda_headers(), timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("trades", [])
|
||||||
|
|
||||||
|
def get_account_summary(self) -> dict:
|
||||||
|
"""Get account balance, NAV, open position count."""
|
||||||
|
base = _oanda_base_url()
|
||||||
|
url = f"{base}/v3/accounts/{self._account_id}/summary"
|
||||||
|
r = requests.get(url, headers=_oanda_headers(), timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
acct = r.json()["account"]
|
||||||
|
return {
|
||||||
|
"balance": float(acct["balance"]),
|
||||||
|
"NAV": float(acct["NAV"]),
|
||||||
|
"open_trade_count": int(acct["openTradeCount"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _place_live_order(self, pair: str, signed_units: int,
|
||||||
|
sl: float, tp: float) -> dict:
|
||||||
|
base = _oanda_base_url()
|
||||||
|
url = f"{base}/v3/accounts/{self._account_id}/orders"
|
||||||
|
|
||||||
|
order = {
|
||||||
|
"type": "MARKET",
|
||||||
|
"instrument": pair,
|
||||||
|
"units": str(signed_units),
|
||||||
|
"timeInForce": "FOK",
|
||||||
|
"positionFill": "DEFAULT",
|
||||||
|
}
|
||||||
|
if sl is not None:
|
||||||
|
order["stopLossOnFill"] = {"price": f"{sl:.5f}", "timeInForce": "GTC"}
|
||||||
|
if tp is not None:
|
||||||
|
order["takeProfitOnFill"] = {"price": f"{tp:.5f}", "timeInForce": "GTC"}
|
||||||
|
|
||||||
|
r = requests.post(url, headers=_oanda_headers(),
|
||||||
|
json={"order": order}, timeout=10)
|
||||||
|
resp = r.json()
|
||||||
|
status = "FILLED" if r.status_code == 201 else f"ERROR_{r.status_code}"
|
||||||
|
direction = "LONG" if signed_units > 0 else "SHORT"
|
||||||
|
|
||||||
|
fill_price = resp.get("orderFillTransaction", {}).get("price", 0)
|
||||||
|
self._log_trade(pair, direction, abs(signed_units), fill_price, status, sl, tp)
|
||||||
|
|
||||||
|
return {"status": status, "mode": "live", "response": resp}
|
||||||
|
|
||||||
|
def _log_trade(self, pair: str, direction: str, units: int,
|
||||||
|
price: float, status: str, sl: float, tp: float):
|
||||||
|
"""Append trade to paper_trades.csv."""
|
||||||
|
columns = [
|
||||||
|
"timestamp", "pair", "direction", "units", "price",
|
||||||
|
"sl", "tp", "status", "mode",
|
||||||
|
]
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
row = {
|
||||||
|
"timestamp": now,
|
||||||
|
"pair": pair,
|
||||||
|
"direction": direction,
|
||||||
|
"units": units,
|
||||||
|
"price": price,
|
||||||
|
"sl": sl or "",
|
||||||
|
"tp": tp or "",
|
||||||
|
"status": status,
|
||||||
|
"mode": "paper" if self.paper_mode else "live",
|
||||||
|
}
|
||||||
|
|
||||||
|
file_exists = self._csv_path.exists()
|
||||||
|
with open(self._csv_path, "a", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=columns)
|
||||||
|
if not file_exists:
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(row)
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Entry point for the Phase 2 live paper trading engine.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python src/live/run.py # continuous loop
|
||||||
|
python src/live/run.py --once # single iteration (for testing)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# Ensure project root on path
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
from src.position_manager import PositionManager
|
||||||
|
from src.live.data_feed import OandaDataFeed
|
||||||
|
from src.live.executor import OandaExecutor
|
||||||
|
from src.live.engine import LiveEngine, StrategySlot
|
||||||
|
|
||||||
|
# Strategy imports
|
||||||
|
from src.strategies_pkg.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||||
|
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||||
|
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
|
||||||
|
from src.strategies_pkg.s3_key_level_breakout import S3_KeyLevel_Breakout
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
"""Load Phase 2 config from system.yaml."""
|
||||||
|
cfg_path = os.path.join(ROOT, "config", "system.yaml")
|
||||||
|
with open(cfg_path) as f:
|
||||||
|
cfg = yaml.safe_load(f)
|
||||||
|
return cfg.get("phase2", {})
|
||||||
|
|
||||||
|
|
||||||
|
def build_slots(config: dict) -> list[StrategySlot]:
|
||||||
|
"""Build StrategySlot instances from config."""
|
||||||
|
starting_equity = config.get("starting_equity", 100_000)
|
||||||
|
slots = []
|
||||||
|
|
||||||
|
# Strategy factory
|
||||||
|
strategy_map = config.get("strategies", [])
|
||||||
|
for entry in strategy_map:
|
||||||
|
if not entry.get("enabled", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = entry["name"]
|
||||||
|
pair = entry["pair"]
|
||||||
|
tf = entry.get("timeframe", "H1")
|
||||||
|
htf_tf = entry.get("htf_timeframe", None)
|
||||||
|
|
||||||
|
# Create strategy instance
|
||||||
|
if name == "S7_Tight":
|
||||||
|
strategy = S7_Liquidity_Sweep()
|
||||||
|
elif name == "S9":
|
||||||
|
strategy = S9_London_Session()
|
||||||
|
elif name == "S9_Filtered":
|
||||||
|
strategy = S9_London_Session(pair=pair, filtered=True)
|
||||||
|
elif name == "S4F":
|
||||||
|
strategy = S4F_EMA_Ribbon()
|
||||||
|
elif name == "S3":
|
||||||
|
strategy = S3_KeyLevel_Breakout()
|
||||||
|
else:
|
||||||
|
print(f"Unknown strategy: {name}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Data feeds
|
||||||
|
data_feed = OandaDataFeed(pair=pair, granularity=tf, candle_count=250)
|
||||||
|
htf_feed = None
|
||||||
|
if htf_tf and htf_tf != tf:
|
||||||
|
htf_feed = OandaDataFeed(pair=pair, granularity=htf_tf, candle_count=250)
|
||||||
|
|
||||||
|
# Position manager (each slot gets its own)
|
||||||
|
pm = PositionManager(pair=pair, starting_equity=starting_equity)
|
||||||
|
|
||||||
|
slot = StrategySlot(
|
||||||
|
name=name,
|
||||||
|
strategy=strategy,
|
||||||
|
pair=pair,
|
||||||
|
timeframe=tf,
|
||||||
|
data_feed=data_feed,
|
||||||
|
position_manager=pm,
|
||||||
|
htf_feed=htf_feed,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
slots.append(slot)
|
||||||
|
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = load_config()
|
||||||
|
if not config:
|
||||||
|
print("ERROR: No phase2 config found in config/system.yaml")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
paper_mode = config.get("paper_mode", True)
|
||||||
|
interval = config.get("poll_interval_seconds", 60)
|
||||||
|
max_dd = config.get("max_daily_drawdown_pct", 5.0)
|
||||||
|
|
||||||
|
# Check OANDA credentials
|
||||||
|
env_path = os.path.join(ROOT, "config", ".env")
|
||||||
|
if os.path.exists(env_path):
|
||||||
|
for line in open(env_path).read().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())
|
||||||
|
|
||||||
|
account_id = os.getenv("OANDA_ACCOUNT_ID", "")
|
||||||
|
if not account_id and not paper_mode:
|
||||||
|
print("ERROR: OANDA_ACCOUNT_ID not set in config/.env")
|
||||||
|
print("Paper trading can run without it, but live mode requires it.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not account_id:
|
||||||
|
print("WARNING: OANDA_ACCOUNT_ID empty — paper mode only, "
|
||||||
|
"data feeds will still work for signal testing.")
|
||||||
|
|
||||||
|
# Build components
|
||||||
|
executor = OandaExecutor(paper_mode=paper_mode)
|
||||||
|
slots = build_slots(config)
|
||||||
|
|
||||||
|
if not slots:
|
||||||
|
print("ERROR: No strategy slots configured.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Phase 2 Live Engine")
|
||||||
|
print(f" Mode: {'PAPER' if paper_mode else 'LIVE'}")
|
||||||
|
print(f" Slots: {len(slots)}")
|
||||||
|
print(f" Poll interval: {interval}s")
|
||||||
|
print(f" Max daily DD: {max_dd}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
engine = LiveEngine(
|
||||||
|
slots=slots,
|
||||||
|
executor=executor,
|
||||||
|
interval_seconds=interval,
|
||||||
|
max_daily_drawdown_pct=max_dd,
|
||||||
|
)
|
||||||
|
engine.load_state()
|
||||||
|
|
||||||
|
if "--once" in sys.argv:
|
||||||
|
engine.run_once()
|
||||||
|
else:
|
||||||
|
engine.run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"""
|
||||||
|
Shared position management for backtester and live engine.
|
||||||
|
|
||||||
|
Extracted from backtester/engine.py to allow both the backtest engine
|
||||||
|
and the live trading engine to share the same position update logic.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spread Configuration (in pips)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SPREAD_PIPS = {
|
||||||
|
"EUR_USD": 1.5, "GBP_USD": 1.5,
|
||||||
|
"GBP_AUD": 2.0, "EUR_AUD": 2.0,
|
||||||
|
"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
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
"""Represents an open position."""
|
||||||
|
entry_time: pd.Timestamp
|
||||||
|
direction: str # 'LONG' or 'SHORT'
|
||||||
|
entry_price: float
|
||||||
|
sl_price: float
|
||||||
|
tp1_price: float
|
||||||
|
tp2_price: float
|
||||||
|
tp3_price: float
|
||||||
|
initial_size: float
|
||||||
|
current_size: float
|
||||||
|
tp_splits: tuple # e.g. (0.40, 0.40, 0.20)
|
||||||
|
trail_atr_mult: float
|
||||||
|
max_bars: int
|
||||||
|
bars_held: int = 0
|
||||||
|
tp1_hit: bool = False
|
||||||
|
tp2_hit: bool = False
|
||||||
|
tp3_hit: bool = False
|
||||||
|
trailing_sl: Optional[float] = None
|
||||||
|
strategy_id: int = 0
|
||||||
|
confluence_score: int = 0
|
||||||
|
signal_features: dict = field(default_factory=dict)
|
||||||
|
realized_pnl: float = 0.0
|
||||||
|
no_breakeven: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeRecord:
|
||||||
|
"""Rich trade log entry for Phase 3 feature engineering."""
|
||||||
|
timestamp: pd.Timestamp = None
|
||||||
|
strategy_id: int = 0
|
||||||
|
pair: str = ""
|
||||||
|
signal_direction: str = ""
|
||||||
|
entry_price: float = 0.0
|
||||||
|
sl_price: float = 0.0
|
||||||
|
tp1_price: float = 0.0
|
||||||
|
tp2_price: float = 0.0
|
||||||
|
tp3_price: float = 0.0
|
||||||
|
lot_size: float = 0.0
|
||||||
|
confluence_score: int = 0
|
||||||
|
session: str = ""
|
||||||
|
spread_at_entry: float = 0.0
|
||||||
|
atr_at_entry: float = 0.0
|
||||||
|
adx_at_entry: float = 0.0
|
||||||
|
rsi_at_entry: float = 0.0
|
||||||
|
ema_50_value: float = 0.0
|
||||||
|
ema_200_value: float = 0.0
|
||||||
|
vwap_deviation: float = 0.0
|
||||||
|
news_within_60min: bool = False
|
||||||
|
macd_hist_at_entry: float = 0.0
|
||||||
|
stoch_k_at_entry: float = 0.0
|
||||||
|
distance_from_ema50_pips: float = 0.0
|
||||||
|
candle_body_ratio: float = 0.0
|
||||||
|
hour_of_day: int = 0
|
||||||
|
day_of_week: int = 0
|
||||||
|
entry_pattern: str = ""
|
||||||
|
exit_price: float = 0.0
|
||||||
|
exit_reason: str = ""
|
||||||
|
exit_time: pd.Timestamp = None
|
||||||
|
pnl_pips: float = 0.0
|
||||||
|
pnl_dollars: float = 0.0
|
||||||
|
hold_time_minutes: int = 0
|
||||||
|
win: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PositionManager:
|
||||||
|
"""Manages open positions and produces TradeRecords on close.
|
||||||
|
|
||||||
|
Used by both the Backtester and the LiveEngine so that position
|
||||||
|
update logic (SL/TP/trail/time exits, partial closes) is shared.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pair: str, starting_equity: float = 100_000.0,
|
||||||
|
pip_size: float = None, spread_pips: float = None):
|
||||||
|
self.pair = pair
|
||||||
|
self.equity = starting_equity
|
||||||
|
self.starting_equity = starting_equity
|
||||||
|
self.peak_equity = starting_equity
|
||||||
|
|
||||||
|
self.pip = pip_size if pip_size is not None else PIP_SIZE.get(pair, 0.0001)
|
||||||
|
spread_p = spread_pips if spread_pips is not None else SPREAD_PIPS.get(pair, 2.0)
|
||||||
|
self.spread = spread_p * self.pip
|
||||||
|
|
||||||
|
self.open_positions: list[Position] = []
|
||||||
|
self.closed_trades: list[TradeRecord] = []
|
||||||
|
self.daily_pnl: dict = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def open_position(self, signal: dict, entry_price: float,
|
||||||
|
timestamp: pd.Timestamp, atr: float,
|
||||||
|
strategy_id: int = 0, signal_features: dict = None,
|
||||||
|
risk_pct: float = 0.01) -> Optional[Position]:
|
||||||
|
"""Create and register a new Position from a strategy signal.
|
||||||
|
|
||||||
|
Returns the Position or None if sizing fails.
|
||||||
|
"""
|
||||||
|
direction = signal["direction"]
|
||||||
|
sl = signal["sl"]
|
||||||
|
|
||||||
|
# Apply spread
|
||||||
|
if direction == "LONG":
|
||||||
|
adj_entry = entry_price + self.spread
|
||||||
|
else:
|
||||||
|
adj_entry = entry_price - self.spread
|
||||||
|
|
||||||
|
# Apply slippage
|
||||||
|
hour = timestamp.hour if hasattr(timestamp, 'hour') else 0
|
||||||
|
slip = 0.4 * self.pip if 8 <= hour < 17 else 1.0 * self.pip
|
||||||
|
if direction == "LONG":
|
||||||
|
adj_entry += slip
|
||||||
|
else:
|
||||||
|
adj_entry -= slip
|
||||||
|
|
||||||
|
sl_dist = abs(adj_entry - sl)
|
||||||
|
if sl_dist <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Minimum RR check
|
||||||
|
tp1_dist = abs(signal["tp1"] - adj_entry)
|
||||||
|
if tp1_dist / sl_dist < 0.5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Position sizing
|
||||||
|
size = self._calculate_position_size(sl_dist, risk_pct)
|
||||||
|
if size <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tp_splits = signal.get("tp_splits", (0.40, 0.40, 0.20))
|
||||||
|
trail_mult = signal.get("trail_atr_mult", 1.5)
|
||||||
|
max_bars = signal.get("max_bars", 200)
|
||||||
|
no_breakeven = signal.get("no_breakeven", False)
|
||||||
|
confluence = signal.get("confluence", 0)
|
||||||
|
|
||||||
|
pos = Position(
|
||||||
|
entry_time=timestamp,
|
||||||
|
direction=direction,
|
||||||
|
entry_price=adj_entry,
|
||||||
|
sl_price=sl,
|
||||||
|
tp1_price=signal["tp1"],
|
||||||
|
tp2_price=signal["tp2"],
|
||||||
|
tp3_price=signal["tp3"],
|
||||||
|
initial_size=size,
|
||||||
|
current_size=size,
|
||||||
|
tp_splits=tp_splits,
|
||||||
|
trail_atr_mult=trail_mult,
|
||||||
|
max_bars=max_bars,
|
||||||
|
strategy_id=strategy_id,
|
||||||
|
confluence_score=confluence,
|
||||||
|
signal_features=signal_features or {},
|
||||||
|
no_breakeven=no_breakeven,
|
||||||
|
)
|
||||||
|
self.open_positions.append(pos)
|
||||||
|
return pos
|
||||||
|
|
||||||
|
def update_positions(self, candle: pd.Series) -> list[TradeRecord]:
|
||||||
|
"""Check SL/TP/trailing/time exits for all open positions.
|
||||||
|
|
||||||
|
Returns list of TradeRecords for positions that were fully closed.
|
||||||
|
"""
|
||||||
|
to_close = []
|
||||||
|
new_records_start = len(self.closed_trades)
|
||||||
|
|
||||||
|
for pos in self.open_positions:
|
||||||
|
pos.bars_held += 1
|
||||||
|
high = candle["high"]
|
||||||
|
low = candle["low"]
|
||||||
|
close_price = candle["close"]
|
||||||
|
current_atr = candle.get("atr_14", 0)
|
||||||
|
|
||||||
|
effective_sl = pos.trailing_sl if pos.trailing_sl is not None else pos.sl_price
|
||||||
|
|
||||||
|
if pos.direction == "LONG":
|
||||||
|
if low <= effective_sl:
|
||||||
|
self._close_position(pos, effective_sl, "SL", candle)
|
||||||
|
to_close.append(pos)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not pos.tp1_hit and high >= pos.tp1_price:
|
||||||
|
close_size = pos.initial_size * pos.tp_splits[0]
|
||||||
|
self._partial_close(pos, pos.tp1_price, close_size, "TP1", candle)
|
||||||
|
pos.tp1_hit = True
|
||||||
|
if not pos.no_breakeven:
|
||||||
|
pos.trailing_sl = pos.entry_price
|
||||||
|
|
||||||
|
if not pos.tp2_hit and pos.tp1_hit and high >= pos.tp2_price:
|
||||||
|
close_size = pos.initial_size * pos.tp_splits[1]
|
||||||
|
if close_size > 0:
|
||||||
|
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
||||||
|
pos.tp2_hit = True
|
||||||
|
if pos.trailing_sl is None:
|
||||||
|
pos.trailing_sl = pos.sl_price
|
||||||
|
|
||||||
|
if not pos.tp3_hit and pos.tp2_hit and high >= pos.tp3_price:
|
||||||
|
self._close_position(pos, pos.tp3_price, "TP3", candle)
|
||||||
|
to_close.append(pos)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pos.tp2_hit and current_atr > 0:
|
||||||
|
new_trail = high - pos.trail_atr_mult * current_atr
|
||||||
|
if pos.trailing_sl is None or new_trail > pos.trailing_sl:
|
||||||
|
pos.trailing_sl = new_trail
|
||||||
|
|
||||||
|
else: # SHORT
|
||||||
|
if high >= effective_sl:
|
||||||
|
self._close_position(pos, effective_sl, "SL", candle)
|
||||||
|
to_close.append(pos)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not pos.tp1_hit and low <= pos.tp1_price:
|
||||||
|
close_size = pos.initial_size * pos.tp_splits[0]
|
||||||
|
self._partial_close(pos, pos.tp1_price, close_size, "TP1", candle)
|
||||||
|
pos.tp1_hit = True
|
||||||
|
if not pos.no_breakeven:
|
||||||
|
pos.trailing_sl = pos.entry_price
|
||||||
|
|
||||||
|
if not pos.tp2_hit and pos.tp1_hit and low <= pos.tp2_price:
|
||||||
|
close_size = pos.initial_size * pos.tp_splits[1]
|
||||||
|
if close_size > 0:
|
||||||
|
self._partial_close(pos, pos.tp2_price, close_size, "TP2", candle)
|
||||||
|
pos.tp2_hit = True
|
||||||
|
if pos.trailing_sl is None:
|
||||||
|
pos.trailing_sl = pos.sl_price
|
||||||
|
|
||||||
|
if not pos.tp3_hit and pos.tp2_hit and low <= pos.tp3_price:
|
||||||
|
self._close_position(pos, pos.tp3_price, "TP3", candle)
|
||||||
|
to_close.append(pos)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pos.tp2_hit and current_atr > 0:
|
||||||
|
new_trail = low + pos.trail_atr_mult * current_atr
|
||||||
|
if pos.trailing_sl is None or new_trail < pos.trailing_sl:
|
||||||
|
pos.trailing_sl = new_trail
|
||||||
|
|
||||||
|
if pos.max_bars > 0 and pos.bars_held >= pos.max_bars:
|
||||||
|
self._close_position(pos, close_price, "TIME", candle)
|
||||||
|
to_close.append(pos)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for pos in to_close:
|
||||||
|
if pos in self.open_positions:
|
||||||
|
self.open_positions.remove(pos)
|
||||||
|
|
||||||
|
return self.closed_trades[new_records_start:]
|
||||||
|
|
||||||
|
def get_open_positions(self) -> list[Position]:
|
||||||
|
return list(self.open_positions)
|
||||||
|
|
||||||
|
def get_equity(self) -> float:
|
||||||
|
return self.equity
|
||||||
|
|
||||||
|
def force_close_all(self, candle: pd.Series) -> list[TradeRecord]:
|
||||||
|
"""Force-close all open positions at candle close price."""
|
||||||
|
new_records_start = len(self.closed_trades)
|
||||||
|
for pos in list(self.open_positions):
|
||||||
|
self._close_position(pos, candle["close"], "END", candle)
|
||||||
|
self.open_positions.clear()
|
||||||
|
return self.closed_trades[new_records_start:]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _calculate_position_size(self, sl_distance: float,
|
||||||
|
risk_pct: float = 0.01) -> float:
|
||||||
|
if sl_distance <= 0:
|
||||||
|
return 0.0
|
||||||
|
risk_amount = self.equity * risk_pct
|
||||||
|
return round(risk_amount / sl_distance, 2)
|
||||||
|
|
||||||
|
def _partial_close(self, pos: Position, exit_price: float,
|
||||||
|
close_size: float, reason: str, candle: pd.Series):
|
||||||
|
if pos.direction == "LONG":
|
||||||
|
pnl_per_unit = exit_price - pos.entry_price
|
||||||
|
else:
|
||||||
|
pnl_per_unit = pos.entry_price - exit_price
|
||||||
|
|
||||||
|
pnl = pnl_per_unit * close_size
|
||||||
|
self.equity += pnl
|
||||||
|
pos.current_size -= close_size
|
||||||
|
pos.realized_pnl += pnl
|
||||||
|
|
||||||
|
date_key = candle.name.date() if hasattr(candle.name, 'date') else None
|
||||||
|
if date_key:
|
||||||
|
self.daily_pnl[date_key] = self.daily_pnl.get(date_key, 0.0) + pnl
|
||||||
|
|
||||||
|
if self.equity > self.peak_equity:
|
||||||
|
self.peak_equity = self.equity
|
||||||
|
|
||||||
|
def _close_position(self, pos: Position, exit_price: float,
|
||||||
|
reason: str, candle: pd.Series):
|
||||||
|
remaining = pos.current_size
|
||||||
|
if remaining <= 0:
|
||||||
|
remaining = 0.01
|
||||||
|
|
||||||
|
if pos.direction == "LONG":
|
||||||
|
pnl_per_unit = exit_price - pos.entry_price
|
||||||
|
else:
|
||||||
|
pnl_per_unit = pos.entry_price - exit_price
|
||||||
|
|
||||||
|
final_pnl = pnl_per_unit * remaining
|
||||||
|
self.equity += final_pnl
|
||||||
|
|
||||||
|
total_pnl = pos.realized_pnl + final_pnl
|
||||||
|
total_pnl_pips = total_pnl / (pos.initial_size * self.pip) if pos.initial_size > 0 else 0
|
||||||
|
|
||||||
|
date_key = candle.name.date() if hasattr(candle.name, 'date') else None
|
||||||
|
if date_key:
|
||||||
|
self.daily_pnl[date_key] = self.daily_pnl.get(date_key, 0.0) + final_pnl
|
||||||
|
|
||||||
|
if self.equity > self.peak_equity:
|
||||||
|
self.peak_equity = self.equity
|
||||||
|
|
||||||
|
exit_time = candle.name
|
||||||
|
hold_minutes = 0
|
||||||
|
if hasattr(exit_time, 'timestamp') and hasattr(pos.entry_time, 'timestamp'):
|
||||||
|
hold_minutes = int((exit_time - pos.entry_time).total_seconds() / 60)
|
||||||
|
|
||||||
|
exit_detail = reason
|
||||||
|
if reason != "TP3":
|
||||||
|
parts = []
|
||||||
|
if pos.tp1_hit:
|
||||||
|
parts.append("TP1")
|
||||||
|
if pos.tp2_hit:
|
||||||
|
parts.append("TP2")
|
||||||
|
parts.append(reason)
|
||||||
|
exit_detail = "+".join(parts)
|
||||||
|
|
||||||
|
features = pos.signal_features
|
||||||
|
record = TradeRecord(
|
||||||
|
timestamp=pos.entry_time,
|
||||||
|
strategy_id=pos.strategy_id,
|
||||||
|
pair=self.pair,
|
||||||
|
signal_direction=pos.direction,
|
||||||
|
entry_price=pos.entry_price,
|
||||||
|
sl_price=pos.sl_price,
|
||||||
|
tp1_price=pos.tp1_price,
|
||||||
|
tp2_price=pos.tp2_price,
|
||||||
|
tp3_price=pos.tp3_price,
|
||||||
|
lot_size=pos.initial_size,
|
||||||
|
confluence_score=pos.confluence_score,
|
||||||
|
session=features.get("session", ""),
|
||||||
|
spread_at_entry=features.get("spread_at_entry", 0),
|
||||||
|
atr_at_entry=features.get("atr_at_entry", 0),
|
||||||
|
adx_at_entry=features.get("adx_at_entry", 0),
|
||||||
|
rsi_at_entry=features.get("rsi_at_entry", 0),
|
||||||
|
ema_50_value=features.get("ema_50_value", 0),
|
||||||
|
ema_200_value=features.get("ema_200_value", 0),
|
||||||
|
vwap_deviation=features.get("vwap_deviation", 0),
|
||||||
|
news_within_60min=features.get("news_within_60min", False),
|
||||||
|
macd_hist_at_entry=features.get("macd_hist_at_entry", 0),
|
||||||
|
stoch_k_at_entry=features.get("stoch_k_at_entry", 0),
|
||||||
|
distance_from_ema50_pips=features.get("distance_from_ema50_pips", 0),
|
||||||
|
candle_body_ratio=features.get("candle_body_ratio", 0),
|
||||||
|
hour_of_day=features.get("hour_of_day", 0),
|
||||||
|
day_of_week=features.get("day_of_week", 0),
|
||||||
|
entry_pattern=features.get("entry_pattern", ""),
|
||||||
|
exit_price=exit_price,
|
||||||
|
exit_reason=exit_detail,
|
||||||
|
exit_time=exit_time,
|
||||||
|
pnl_pips=total_pnl_pips,
|
||||||
|
pnl_dollars=total_pnl,
|
||||||
|
hold_time_minutes=hold_minutes,
|
||||||
|
win=total_pnl > 0,
|
||||||
|
)
|
||||||
|
self.closed_trades.append(record)
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Phase 2 — Correlation Analysis (Step 8).
|
||||||
|
|
||||||
|
For strategies sharing a pair (S7_Tight + S3 on GBP_JPY):
|
||||||
|
- Compute signal overlap and simultaneous position frequency.
|
||||||
|
- Combined equity curve analysis.
|
||||||
|
|
||||||
|
Also compute portfolio-level metrics: combined PF, combined max DD,
|
||||||
|
Sharpe of combined equity curve.
|
||||||
|
|
||||||
|
Output: results/phase2/correlation_analysis.json
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
import numpy as np
|
||||||
|
from src.indicators.technical import compute_all_indicators
|
||||||
|
from src.backtester.engine import Backtester
|
||||||
|
|
||||||
|
# Strategy imports
|
||||||
|
from src.strategies_pkg.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||||
|
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||||
|
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
|
||||||
|
from src.strategies_pkg.s3_key_level_breakout import S3_KeyLevel_Breakout
|
||||||
|
|
||||||
|
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", "phase2")
|
||||||
|
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# All Phase 2 strategies
|
||||||
|
CONFIGS = [
|
||||||
|
{"name": "S7_Tight", "pair": "GBP_JPY", "tf": "H1",
|
||||||
|
"factory": lambda: S7_Liquidity_Sweep()},
|
||||||
|
{"name": "S9", "pair": "GBP_USD", "tf": "H1",
|
||||||
|
"factory": lambda: S9_London_Session()},
|
||||||
|
{"name": "S9_Filtered", "pair": "GBP_AUD", "tf": "H1",
|
||||||
|
"factory": lambda: S9_London_Session(pair="GBP_AUD", filtered=True)},
|
||||||
|
{"name": "S4F", "pair": "EUR_AUD", "tf": "M15",
|
||||||
|
"factory": lambda: S4F_EMA_Ribbon()},
|
||||||
|
{"name": "S3", "pair": "GBP_JPY", "tf": "H1",
|
||||||
|
"factory": lambda: S3_KeyLevel_Breakout()},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(pair, tf):
|
||||||
|
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||||
|
if not os.path.exists(fp):
|
||||||
|
return None
|
||||||
|
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||||
|
df.index.name = "timestamp"
|
||||||
|
return compute_all_indicators(df)
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(cfg):
|
||||||
|
"""Run backtest for a config, return trade log and equity curve."""
|
||||||
|
pair = cfg["pair"]
|
||||||
|
tf = cfg["tf"]
|
||||||
|
data = load_data(pair, tf)
|
||||||
|
if data is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
htf_data = data.copy() if tf == "H1" else load_data(pair, "H1")
|
||||||
|
|
||||||
|
strategy = cfg["factory"]()
|
||||||
|
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()
|
||||||
|
eq_curve = pd.DataFrame(bt.equity_curve)
|
||||||
|
return report, trade_log, eq_curve
|
||||||
|
|
||||||
|
|
||||||
|
def compute_signal_overlap(log_a, log_b, pair):
|
||||||
|
"""Compute signal overlap between two strategies on the same pair.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- overlap_count: trades that are open at the same time
|
||||||
|
- same_direction_count: overlapping trades in same direction
|
||||||
|
- opposite_direction_count: overlapping trades in opposite direction
|
||||||
|
- overlap_ratio: fraction of trades that overlap
|
||||||
|
"""
|
||||||
|
if log_a.empty or log_b.empty:
|
||||||
|
return {"overlap_count": 0, "same_dir": 0, "opposite_dir": 0, "ratio": 0}
|
||||||
|
|
||||||
|
overlap = 0
|
||||||
|
same_dir = 0
|
||||||
|
opp_dir = 0
|
||||||
|
|
||||||
|
for _, trade_a in log_a.iterrows():
|
||||||
|
a_start = pd.Timestamp(trade_a["timestamp"])
|
||||||
|
a_end = pd.Timestamp(trade_a["exit_time"]) if pd.notna(trade_a.get("exit_time")) else a_start
|
||||||
|
|
||||||
|
for _, trade_b in log_b.iterrows():
|
||||||
|
b_start = pd.Timestamp(trade_b["timestamp"])
|
||||||
|
b_end = pd.Timestamp(trade_b["exit_time"]) if pd.notna(trade_b.get("exit_time")) else b_start
|
||||||
|
|
||||||
|
# Check if time ranges overlap
|
||||||
|
if a_start <= b_end and b_start <= a_end:
|
||||||
|
overlap += 1
|
||||||
|
if trade_a["signal_direction"] == trade_b["signal_direction"]:
|
||||||
|
same_dir += 1
|
||||||
|
else:
|
||||||
|
opp_dir += 1
|
||||||
|
|
||||||
|
total = len(log_a) + len(log_b)
|
||||||
|
ratio = (overlap * 2) / total if total > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overlap_count": overlap,
|
||||||
|
"same_dir": same_dir,
|
||||||
|
"opposite_dir": opp_dir,
|
||||||
|
"ratio": round(ratio, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_combined_equity(eq_curves: list[pd.DataFrame]) -> pd.DataFrame:
|
||||||
|
"""Combine equity curves from multiple strategies into portfolio equity."""
|
||||||
|
combined = None
|
||||||
|
for eq in eq_curves:
|
||||||
|
if eq is None or eq.empty:
|
||||||
|
continue
|
||||||
|
eq = eq.set_index("timestamp")["equity"]
|
||||||
|
# Convert to returns relative to starting equity
|
||||||
|
returns = eq - 100_000.0
|
||||||
|
if combined is None:
|
||||||
|
combined = returns
|
||||||
|
else:
|
||||||
|
combined = combined.add(returns, fill_value=0)
|
||||||
|
|
||||||
|
if combined is None:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Add back starting equity (100k per slot, or just use combined returns)
|
||||||
|
combined = combined + 100_000.0
|
||||||
|
return combined.reset_index()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_portfolio_metrics(all_trade_logs: list[pd.DataFrame]) -> dict:
|
||||||
|
"""Compute portfolio-level metrics from combined trade logs."""
|
||||||
|
combined = pd.concat([log for log in all_trade_logs if not log.empty],
|
||||||
|
ignore_index=True)
|
||||||
|
if combined.empty:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
n = len(combined)
|
||||||
|
wins = combined[combined["win"] == True]
|
||||||
|
losses = combined[combined["win"] == False]
|
||||||
|
|
||||||
|
wr = len(wins) / n * 100 if n > 0 else 0
|
||||||
|
gross_profit = wins["pnl_pips"].sum() if len(wins) > 0 else 0
|
||||||
|
gross_loss = abs(losses["pnl_pips"].sum()) if len(losses) > 0 else 0
|
||||||
|
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||||
|
total_pnl = combined["pnl_pips"].sum()
|
||||||
|
|
||||||
|
# Combined max drawdown
|
||||||
|
cum_pnl = combined.sort_values("timestamp")["pnl_dollars"].cumsum()
|
||||||
|
peak = cum_pnl.cummax()
|
||||||
|
dd = cum_pnl - peak
|
||||||
|
max_dd = dd.min()
|
||||||
|
max_dd_pct = max_dd / 100_000 * 100 if max_dd < 0 else 0
|
||||||
|
|
||||||
|
# Sharpe ratio
|
||||||
|
daily_pnl = combined.copy()
|
||||||
|
daily_pnl["date"] = pd.to_datetime(daily_pnl["timestamp"]).dt.date
|
||||||
|
daily = daily_pnl.groupby("date")["pnl_dollars"].sum()
|
||||||
|
if len(daily) > 1 and daily.std() > 0:
|
||||||
|
sharpe = (daily.mean() / daily.std()) * np.sqrt(252)
|
||||||
|
else:
|
||||||
|
sharpe = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_trades": n,
|
||||||
|
"win_rate_pct": round(wr, 1),
|
||||||
|
"profit_factor": round(pf, 2),
|
||||||
|
"total_pnl_pips": round(total_pnl, 1),
|
||||||
|
"total_pnl_dollars": round(combined["pnl_dollars"].sum(), 2),
|
||||||
|
"max_drawdown_pct": round(max_dd_pct, 2),
|
||||||
|
"sharpe_ratio": round(sharpe, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"{'='*80}")
|
||||||
|
print("PHASE 2 — CORRELATION ANALYSIS")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
trade_logs = {}
|
||||||
|
eq_curves = {}
|
||||||
|
|
||||||
|
# Run all backtests
|
||||||
|
for cfg in CONFIGS:
|
||||||
|
name = cfg["name"]
|
||||||
|
pair = cfg["pair"]
|
||||||
|
print(f"\nRunning {name} / {pair}...", end=" ", flush=True)
|
||||||
|
t0 = time.time()
|
||||||
|
report, log, eq = run_backtest(cfg)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
n_trades = len(log) if log is not None and not log.empty else 0
|
||||||
|
print(f"{n_trades} trades ({elapsed:.0f}s)")
|
||||||
|
trade_logs[name] = log
|
||||||
|
eq_curves[name] = eq
|
||||||
|
|
||||||
|
# --- Signal Overlap: S7_Tight vs S3 on GBP_JPY ---
|
||||||
|
print(f"\n{'#'*60}")
|
||||||
|
print("# Signal Overlap: S7_Tight vs S3 on GBP_JPY")
|
||||||
|
print(f"{'#'*60}")
|
||||||
|
|
||||||
|
log_s7 = trade_logs.get("S7_Tight", pd.DataFrame())
|
||||||
|
log_s3 = trade_logs.get("S3", pd.DataFrame())
|
||||||
|
|
||||||
|
if not log_s7.empty and not log_s3.empty:
|
||||||
|
overlap = compute_signal_overlap(log_s7, log_s3, "GBP_JPY")
|
||||||
|
results["S7_S3_overlap"] = overlap
|
||||||
|
|
||||||
|
print(f" S7 trades: {len(log_s7)}")
|
||||||
|
print(f" S3 trades: {len(log_s3)}")
|
||||||
|
print(f" Overlapping periods: {overlap['overlap_count']}")
|
||||||
|
print(f" Same direction: {overlap['same_dir']}")
|
||||||
|
print(f" Opposite direction: {overlap['opposite_dir']}")
|
||||||
|
print(f" Overlap ratio: {overlap['ratio']:.1%}")
|
||||||
|
|
||||||
|
if overlap['ratio'] < 0.15:
|
||||||
|
print(" => LOW overlap: Good diversification!")
|
||||||
|
elif overlap['ratio'] < 0.30:
|
||||||
|
print(" => MODERATE overlap: Some clustering.")
|
||||||
|
else:
|
||||||
|
print(" => HIGH overlap: Significant clustering risk.")
|
||||||
|
else:
|
||||||
|
print(" Insufficient data for overlap analysis.")
|
||||||
|
|
||||||
|
# --- S9 vs S9_Filtered (different pairs, should be independent) ---
|
||||||
|
print(f"\n{'#'*60}")
|
||||||
|
print("# Independence Check: S9 (GBP_USD) vs S9_Filtered (GBP_AUD)")
|
||||||
|
print(f"{'#'*60}")
|
||||||
|
|
||||||
|
log_s9 = trade_logs.get("S9", pd.DataFrame())
|
||||||
|
log_s9f = trade_logs.get("S9_Filtered", pd.DataFrame())
|
||||||
|
|
||||||
|
if not log_s9.empty and not log_s9f.empty:
|
||||||
|
# Check temporal clustering (same-day entries)
|
||||||
|
s9_dates = set(pd.to_datetime(log_s9["timestamp"]).dt.date)
|
||||||
|
s9f_dates = set(pd.to_datetime(log_s9f["timestamp"]).dt.date)
|
||||||
|
shared_dates = s9_dates & s9f_dates
|
||||||
|
temporal_overlap = len(shared_dates) / max(len(s9_dates), 1)
|
||||||
|
|
||||||
|
results["S9_S9F_temporal"] = {
|
||||||
|
"s9_trade_days": len(s9_dates),
|
||||||
|
"s9f_trade_days": len(s9f_dates),
|
||||||
|
"shared_trade_days": len(shared_dates),
|
||||||
|
"temporal_overlap_ratio": round(temporal_overlap, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f" S9 trade days: {len(s9_dates)}")
|
||||||
|
print(f" S9_Filtered trade days: {len(s9f_dates)}")
|
||||||
|
print(f" Shared trade days: {len(shared_dates)}")
|
||||||
|
print(f" Temporal overlap: {temporal_overlap:.1%}")
|
||||||
|
else:
|
||||||
|
print(" Insufficient data.")
|
||||||
|
|
||||||
|
# --- Portfolio Metrics ---
|
||||||
|
print(f"\n{'#'*60}")
|
||||||
|
print("# Portfolio-Level Metrics (All 5 Strategies Combined)")
|
||||||
|
print(f"{'#'*60}")
|
||||||
|
|
||||||
|
all_logs = [log for log in trade_logs.values()
|
||||||
|
if log is not None and not log.empty]
|
||||||
|
if all_logs:
|
||||||
|
portfolio = compute_portfolio_metrics(all_logs)
|
||||||
|
results["portfolio"] = portfolio
|
||||||
|
|
||||||
|
print(f" Total trades: {portfolio['total_trades']}")
|
||||||
|
print(f" Win rate: {portfolio['win_rate_pct']}%")
|
||||||
|
print(f" Profit factor: {portfolio['profit_factor']}")
|
||||||
|
print(f" Total PnL (pips): {portfolio['total_pnl_pips']:+.1f}")
|
||||||
|
print(f" Total PnL ($): {portfolio['total_pnl_dollars']:+,.2f}")
|
||||||
|
print(f" Max drawdown: {portfolio['max_drawdown_pct']:.2f}%")
|
||||||
|
print(f" Sharpe ratio: {portfolio['sharpe_ratio']:.2f}")
|
||||||
|
|
||||||
|
# --- Per-Strategy Summary ---
|
||||||
|
print(f"\n{'='*80}")
|
||||||
|
print("STRATEGY SUMMARY")
|
||||||
|
print(f"{'='*80}")
|
||||||
|
print(f"{'Strategy':<16} {'Pair':<10} {'Trades':>6} {'WR%':>6} {'PF':>6} {'PnL(p)':>9}")
|
||||||
|
print(f"{'-'*60}")
|
||||||
|
|
||||||
|
for cfg in CONFIGS:
|
||||||
|
name = cfg["name"]
|
||||||
|
pair = cfg["pair"]
|
||||||
|
log = trade_logs.get(name, pd.DataFrame())
|
||||||
|
if log.empty:
|
||||||
|
print(f"{name:<16} {pair:<10} {'N/A':>6}")
|
||||||
|
continue
|
||||||
|
n = len(log)
|
||||||
|
wins = log[log["win"] == True]
|
||||||
|
wr = len(wins) / n * 100 if n > 0 else 0
|
||||||
|
gp = wins["pnl_pips"].sum() if len(wins) > 0 else 0
|
||||||
|
gl = abs(log[log["win"] == False]["pnl_pips"].sum())
|
||||||
|
pf = gp / gl if gl > 0 else 0
|
||||||
|
pnl = log["pnl_pips"].sum()
|
||||||
|
print(f"{name:<16} {pair:<10} {n:>6} {wr:>5.1f}% {pf:>5.2f} {pnl:>+8.1f}")
|
||||||
|
|
||||||
|
# Save
|
||||||
|
out_path = os.path.join(RESULTS_DIR, "correlation_analysis.json")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
json.dump(results, f, indent=2, default=str)
|
||||||
|
print(f"\nResults saved: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Phase 2 — Kelly Criterion Sizing (Step 9).
|
||||||
|
|
||||||
|
For each Phase 2 strategy, compute:
|
||||||
|
- Kelly optimal fraction: f* = (W * R - L) / R
|
||||||
|
- Half-Kelly as practical recommendation
|
||||||
|
- Monte Carlo simulation (1000 paths) to estimate drawdown distribution
|
||||||
|
|
||||||
|
Output: results/phase2/kelly_sizing.json
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
import numpy as np
|
||||||
|
from src.indicators.technical import compute_all_indicators
|
||||||
|
from src.backtester.engine import Backtester
|
||||||
|
|
||||||
|
# Strategy imports
|
||||||
|
from src.strategies_pkg.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||||
|
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||||
|
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
|
||||||
|
from src.strategies_pkg.s3_key_level_breakout import S3_KeyLevel_Breakout
|
||||||
|
|
||||||
|
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", "phase2")
|
||||||
|
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
CONFIGS = [
|
||||||
|
{"name": "S7_Tight", "pair": "GBP_JPY", "tf": "H1",
|
||||||
|
"factory": lambda: S7_Liquidity_Sweep()},
|
||||||
|
{"name": "S9", "pair": "GBP_USD", "tf": "H1",
|
||||||
|
"factory": lambda: S9_London_Session()},
|
||||||
|
{"name": "S9_Filtered", "pair": "GBP_AUD", "tf": "H1",
|
||||||
|
"factory": lambda: S9_London_Session(pair="GBP_AUD", filtered=True)},
|
||||||
|
{"name": "S4F", "pair": "EUR_AUD", "tf": "M15",
|
||||||
|
"factory": lambda: S4F_EMA_Ribbon()},
|
||||||
|
{"name": "S3", "pair": "GBP_JPY", "tf": "H1",
|
||||||
|
"factory": lambda: S3_KeyLevel_Breakout()},
|
||||||
|
]
|
||||||
|
|
||||||
|
N_SIMULATIONS = 1000
|
||||||
|
STARTING_EQUITY = 100_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(pair, tf):
|
||||||
|
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||||
|
if not os.path.exists(fp):
|
||||||
|
return None
|
||||||
|
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||||
|
df.index.name = "timestamp"
|
||||||
|
return compute_all_indicators(df)
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(cfg):
|
||||||
|
pair = cfg["pair"]
|
||||||
|
tf = cfg["tf"]
|
||||||
|
data = load_data(pair, tf)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
htf_data = data.copy() if tf == "H1" else load_data(pair, "H1")
|
||||||
|
strategy = cfg["factory"]()
|
||||||
|
bt = Backtester(data=data, strategy=strategy, pair=pair,
|
||||||
|
starting_equity=STARTING_EQUITY, htf_data=htf_data)
|
||||||
|
bt.run()
|
||||||
|
return bt.get_trade_log_df()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_kelly(trade_log):
|
||||||
|
"""Compute Kelly criterion from trade log.
|
||||||
|
|
||||||
|
f* = (W * R - L) / R
|
||||||
|
where:
|
||||||
|
W = win rate (probability of winning)
|
||||||
|
L = loss rate (1 - W)
|
||||||
|
R = avg_win / avg_loss (win/loss ratio)
|
||||||
|
|
||||||
|
Returns dict with kelly_full, kelly_half, and component values.
|
||||||
|
"""
|
||||||
|
if trade_log.empty or len(trade_log) < 10:
|
||||||
|
return {
|
||||||
|
"trades": len(trade_log) if trade_log is not None else 0,
|
||||||
|
"kelly_full_pct": 0, "kelly_half_pct": 0,
|
||||||
|
"win_rate": 0, "avg_win_loss_ratio": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
wins = trade_log[trade_log["win"] == True]
|
||||||
|
losses = trade_log[trade_log["win"] == False]
|
||||||
|
|
||||||
|
n = len(trade_log)
|
||||||
|
w = len(wins) / n # win probability
|
||||||
|
l = 1 - w # loss probability
|
||||||
|
|
||||||
|
avg_win = wins["pnl_pips"].mean() if len(wins) > 0 else 0
|
||||||
|
avg_loss = abs(losses["pnl_pips"].mean()) if len(losses) > 0 else 1
|
||||||
|
|
||||||
|
r = avg_win / avg_loss if avg_loss > 0 else 0
|
||||||
|
|
||||||
|
# Kelly formula: f* = (W * R - L) / R
|
||||||
|
if r > 0:
|
||||||
|
kelly = (w * r - l) / r
|
||||||
|
else:
|
||||||
|
kelly = 0
|
||||||
|
|
||||||
|
kelly = max(kelly, 0) # clamp at 0 (never bet negative)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trades": n,
|
||||||
|
"win_rate": round(w * 100, 1),
|
||||||
|
"avg_win_pips": round(avg_win, 1),
|
||||||
|
"avg_loss_pips": round(avg_loss, 1),
|
||||||
|
"avg_win_loss_ratio": round(r, 2),
|
||||||
|
"kelly_full_pct": round(kelly * 100, 2),
|
||||||
|
"kelly_half_pct": round(kelly * 50, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def monte_carlo_drawdown(trade_log, fraction: float,
|
||||||
|
n_sims: int = N_SIMULATIONS) -> dict:
|
||||||
|
"""Monte Carlo simulation to estimate drawdown distribution.
|
||||||
|
|
||||||
|
Randomly resamples trade outcomes (with replacement) to build
|
||||||
|
equity paths and measure max drawdown at the given Kelly fraction.
|
||||||
|
|
||||||
|
Returns percentile drawdown estimates.
|
||||||
|
"""
|
||||||
|
if trade_log.empty or len(trade_log) < 10 or fraction <= 0:
|
||||||
|
return {"p50_dd": 0, "p75_dd": 0, "p95_dd": 0, "p99_dd": 0, "ruin_pct": 0}
|
||||||
|
|
||||||
|
# Use pnl_pips as return per trade (normalized)
|
||||||
|
returns = trade_log["pnl_pips"].values
|
||||||
|
n_trades = len(returns)
|
||||||
|
|
||||||
|
max_drawdowns = []
|
||||||
|
ruin_count = 0
|
||||||
|
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
|
||||||
|
for _ in range(n_sims):
|
||||||
|
# Resample trades with replacement
|
||||||
|
sampled = rng.choice(returns, size=n_trades, replace=True)
|
||||||
|
|
||||||
|
# Scale by fraction (Kelly fraction applied to risk)
|
||||||
|
equity = STARTING_EQUITY
|
||||||
|
peak = equity
|
||||||
|
max_dd = 0
|
||||||
|
|
||||||
|
for ret in sampled:
|
||||||
|
# PnL scaled by fraction (relative to 1% base risk)
|
||||||
|
pnl = ret * fraction / 0.01 * equity / STARTING_EQUITY
|
||||||
|
equity += pnl
|
||||||
|
|
||||||
|
if equity > peak:
|
||||||
|
peak = equity
|
||||||
|
dd = (peak - equity) / peak * 100
|
||||||
|
if dd > max_dd:
|
||||||
|
max_dd = dd
|
||||||
|
|
||||||
|
if equity <= 0:
|
||||||
|
ruin_count += 1
|
||||||
|
max_dd = 100
|
||||||
|
break
|
||||||
|
|
||||||
|
max_drawdowns.append(max_dd)
|
||||||
|
|
||||||
|
dd_array = np.array(max_drawdowns)
|
||||||
|
return {
|
||||||
|
"p50_dd": round(np.percentile(dd_array, 50), 1),
|
||||||
|
"p75_dd": round(np.percentile(dd_array, 75), 1),
|
||||||
|
"p95_dd": round(np.percentile(dd_array, 95), 1),
|
||||||
|
"p99_dd": round(np.percentile(dd_array, 99), 1),
|
||||||
|
"ruin_pct": round(ruin_count / n_sims * 100, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"{'='*90}")
|
||||||
|
print("PHASE 2 — KELLY CRITERION SIZING")
|
||||||
|
print(f"{'='*90}")
|
||||||
|
|
||||||
|
all_results = {}
|
||||||
|
|
||||||
|
for cfg in CONFIGS:
|
||||||
|
name = cfg["name"]
|
||||||
|
pair = cfg["pair"]
|
||||||
|
|
||||||
|
print(f"\n{'#'*60}")
|
||||||
|
print(f"# {name} / {pair}")
|
||||||
|
print(f"{'#'*60}")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
trade_log = run_backtest(cfg)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
|
||||||
|
if trade_log is None or trade_log.empty:
|
||||||
|
print(f" No trades. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Backtest: {len(trade_log)} trades ({elapsed:.0f}s)")
|
||||||
|
|
||||||
|
# Kelly computation
|
||||||
|
kelly = compute_kelly(trade_log)
|
||||||
|
print(f" Win rate: {kelly['win_rate']}%")
|
||||||
|
print(f" Avg win/loss ratio: {kelly['avg_win_loss_ratio']}")
|
||||||
|
print(f" Kelly full: {kelly['kelly_full_pct']:.1f}%")
|
||||||
|
print(f" Kelly half: {kelly['kelly_half_pct']:.1f}% (recommended)")
|
||||||
|
|
||||||
|
# Monte Carlo at half-Kelly
|
||||||
|
half_kelly_frac = kelly["kelly_half_pct"] / 100
|
||||||
|
if half_kelly_frac > 0:
|
||||||
|
print(f"\n Monte Carlo ({N_SIMULATIONS} paths at half-Kelly={kelly['kelly_half_pct']:.1f}%):")
|
||||||
|
mc = monte_carlo_drawdown(trade_log, half_kelly_frac)
|
||||||
|
print(f" Median max DD: {mc['p50_dd']:.1f}%")
|
||||||
|
print(f" 75th pctl DD: {mc['p75_dd']:.1f}%")
|
||||||
|
print(f" 95th pctl DD: {mc['p95_dd']:.1f}%")
|
||||||
|
print(f" 99th pctl DD: {mc['p99_dd']:.1f}%")
|
||||||
|
print(f" Ruin probability: {mc['ruin_pct']:.1f}%")
|
||||||
|
else:
|
||||||
|
mc = {"p50_dd": 0, "p75_dd": 0, "p95_dd": 0, "p99_dd": 0, "ruin_pct": 0}
|
||||||
|
print(f" Kelly <= 0, no MC simulation.")
|
||||||
|
|
||||||
|
all_results[f"{name}_{pair}"] = {
|
||||||
|
**kelly,
|
||||||
|
"monte_carlo_half_kelly": mc,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Summary table
|
||||||
|
print(f"\n{'='*90}")
|
||||||
|
print("KELLY SIZING SUMMARY")
|
||||||
|
print(f"{'='*90}")
|
||||||
|
print(f"{'Strategy':<20} {'Trades':>6} {'WR%':>6} {'W/L':>5} "
|
||||||
|
f"{'Kelly%':>7} {'HalfK%':>7} {'MC-p95DD':>9}")
|
||||||
|
print(f"{'-'*70}")
|
||||||
|
|
||||||
|
for key, result in all_results.items():
|
||||||
|
mc = result.get("monte_carlo_half_kelly", {})
|
||||||
|
print(f"{key:<20} {result['trades']:>6} {result['win_rate']:>5.1f}% "
|
||||||
|
f"{result['avg_win_loss_ratio']:>4.2f} "
|
||||||
|
f"{result['kelly_full_pct']:>6.1f}% {result['kelly_half_pct']:>6.1f}% "
|
||||||
|
f"{mc.get('p95_dd', 0):>8.1f}%")
|
||||||
|
|
||||||
|
# Save
|
||||||
|
out_path = os.path.join(RESULTS_DIR, "kelly_sizing.json")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
json.dump(all_results, f, indent=2, default=str)
|
||||||
|
print(f"\nResults saved: {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
Phase 2 — Regime Analysis (Step 7).
|
||||||
|
|
||||||
|
Runs each Phase 2 strategy on the full dataset, breaks down results by year
|
||||||
|
(2021, 2022, 2023). Answers: "Is the edge strengthening or was OOS lucky?"
|
||||||
|
|
||||||
|
Output: results/phase2/regime_analysis.json + console table.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
import numpy as np
|
||||||
|
from src.indicators.technical import compute_all_indicators
|
||||||
|
from src.backtester.engine import Backtester
|
||||||
|
|
||||||
|
# Strategy imports
|
||||||
|
from src.strategies_pkg.s7_liquidity_sweep import S7_Liquidity_Sweep
|
||||||
|
from src.strategies_pkg.s9_london_session import S9_London_Session
|
||||||
|
from src.strategies_pkg.s4f_ema_ribbon import S4F_EMA_Ribbon
|
||||||
|
from src.strategies_pkg.s3_key_level_breakout import S3_KeyLevel_Breakout
|
||||||
|
|
||||||
|
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", "phase2")
|
||||||
|
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Phase 2 strategy-pair configurations
|
||||||
|
CONFIGS = [
|
||||||
|
{"name": "S7_Tight", "pair": "GBP_JPY", "tf": "H1", "htf_tf": "H1",
|
||||||
|
"factory": lambda: S7_Liquidity_Sweep()},
|
||||||
|
{"name": "S9", "pair": "GBP_USD", "tf": "H1", "htf_tf": "H1",
|
||||||
|
"factory": lambda: S9_London_Session()},
|
||||||
|
{"name": "S9_Filtered", "pair": "GBP_AUD", "tf": "H1", "htf_tf": "H1",
|
||||||
|
"factory": lambda: S9_London_Session(pair="GBP_AUD", filtered=True)},
|
||||||
|
{"name": "S4F", "pair": "EUR_AUD", "tf": "M15", "htf_tf": "H1",
|
||||||
|
"factory": lambda: S4F_EMA_Ribbon()},
|
||||||
|
{"name": "S3", "pair": "GBP_JPY", "tf": "H1", "htf_tf": "H1",
|
||||||
|
"factory": lambda: S3_KeyLevel_Breakout()},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(pair, tf):
|
||||||
|
"""Load price data with indicators."""
|
||||||
|
fp = os.path.join(PROCESSED_DIR, f"{pair}_{tf}.csv")
|
||||||
|
if not os.path.exists(fp):
|
||||||
|
print(f" WARNING: {fp} not found")
|
||||||
|
return None
|
||||||
|
df = pd.read_csv(fp, index_col=0, parse_dates=True)
|
||||||
|
df.index.name = "timestamp"
|
||||||
|
return compute_all_indicators(df)
|
||||||
|
|
||||||
|
|
||||||
|
def slice_year(df, year):
|
||||||
|
"""Slice dataframe to a specific year, with 60 days warmup prepended."""
|
||||||
|
# Match timezone awareness of the dataframe index
|
||||||
|
if df.index.tz is not None:
|
||||||
|
start = pd.Timestamp(f"{year}-01-01", tz=df.index.tz)
|
||||||
|
end = pd.Timestamp(f"{year}-12-31 23:59:59", tz=df.index.tz)
|
||||||
|
else:
|
||||||
|
start = pd.Timestamp(f"{year}-01-01")
|
||||||
|
end = pd.Timestamp(f"{year}-12-31 23:59:59")
|
||||||
|
warmup_start = start - pd.DateOffset(days=60)
|
||||||
|
sliced = df[(df.index >= warmup_start) & (df.index <= end)].copy()
|
||||||
|
return sliced, start
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(strategy, data, htf_data, pair, start_after=None):
|
||||||
|
"""Run backtest, optionally filtering trades after a start date."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Filter trades to only those after start_after (exclude warmup trades)
|
||||||
|
if start_after is not None and not trade_log.empty:
|
||||||
|
ts = pd.to_datetime(trade_log["timestamp"])
|
||||||
|
start_ts = pd.Timestamp(start_after)
|
||||||
|
# Align timezone awareness
|
||||||
|
if ts.dt.tz is not None and start_ts.tz is None:
|
||||||
|
start_ts = start_ts.tz_localize(ts.dt.tz)
|
||||||
|
elif ts.dt.tz is None and start_ts.tz is not None:
|
||||||
|
start_ts = start_ts.tz_localize(None)
|
||||||
|
trade_log = trade_log[ts >= start_ts]
|
||||||
|
|
||||||
|
return report, trade_log
|
||||||
|
|
||||||
|
|
||||||
|
def compute_metrics(trade_log):
|
||||||
|
"""Compute metrics from a trade log DataFrame."""
|
||||||
|
if trade_log.empty or len(trade_log) == 0:
|
||||||
|
return {
|
||||||
|
"trades": 0, "wr": 0, "pf": 0, "pnl_pips": 0,
|
||||||
|
"max_dd_pips": 0, "expectancy": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
n = len(trade_log)
|
||||||
|
wins = trade_log[trade_log["win"] == True]
|
||||||
|
losses = trade_log[trade_log["win"] == False]
|
||||||
|
|
||||||
|
wr = len(wins) / n * 100 if n > 0 else 0
|
||||||
|
gross_profit = wins["pnl_pips"].sum() if len(wins) > 0 else 0
|
||||||
|
gross_loss = abs(losses["pnl_pips"].sum()) if len(losses) > 0 else 0
|
||||||
|
pf = gross_profit / gross_loss if gross_loss > 0 else float("inf")
|
||||||
|
total_pnl = trade_log["pnl_pips"].sum()
|
||||||
|
expectancy = total_pnl / n if n > 0 else 0
|
||||||
|
|
||||||
|
# Max drawdown in pips (cumulative PnL drawdown)
|
||||||
|
cum_pnl = trade_log["pnl_pips"].cumsum()
|
||||||
|
peak = cum_pnl.cummax()
|
||||||
|
dd = cum_pnl - peak
|
||||||
|
max_dd = dd.min() if len(dd) > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trades": n,
|
||||||
|
"wr": round(wr, 1),
|
||||||
|
"pf": round(pf, 2),
|
||||||
|
"pnl_pips": round(total_pnl, 1),
|
||||||
|
"max_dd_pips": round(max_dd, 1),
|
||||||
|
"expectancy": round(expectancy, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
years = [2021, 2022, 2023]
|
||||||
|
all_results = {}
|
||||||
|
|
||||||
|
print(f"{'='*90}")
|
||||||
|
print("PHASE 2 — REGIME ANALYSIS (Per-Year Breakdown)")
|
||||||
|
print(f"{'='*90}")
|
||||||
|
|
||||||
|
for cfg in CONFIGS:
|
||||||
|
name = cfg["name"]
|
||||||
|
pair = cfg["pair"]
|
||||||
|
tf = cfg["tf"]
|
||||||
|
htf_tf = cfg["htf_tf"]
|
||||||
|
|
||||||
|
print(f"\n{'#'*70}")
|
||||||
|
print(f"# {name} / {pair} ({tf})")
|
||||||
|
print(f"{'#'*70}")
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
data = load_data(pair, tf)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
htf_data = data.copy() if htf_tf == tf else load_data(pair, htf_tf)
|
||||||
|
if htf_data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
strategy_results = {"pair": pair, "timeframe": tf, "years": {}}
|
||||||
|
|
||||||
|
# Full dataset first
|
||||||
|
strategy = cfg["factory"]()
|
||||||
|
_, full_log = run_backtest(strategy, data, htf_data, pair)
|
||||||
|
full_metrics = compute_metrics(full_log)
|
||||||
|
strategy_results["full"] = full_metrics
|
||||||
|
print(f" {'FULL':>6}: {full_metrics['trades']:>4} trades | "
|
||||||
|
f"WR {full_metrics['wr']:>5.1f}% | PF {full_metrics['pf']:>5.2f} | "
|
||||||
|
f"PnL {full_metrics['pnl_pips']:>+8.1f}p | "
|
||||||
|
f"DD {full_metrics['max_dd_pips']:>+8.1f}p | "
|
||||||
|
f"Exp {full_metrics['expectancy']:>+6.2f}p")
|
||||||
|
|
||||||
|
# Per-year breakdown
|
||||||
|
for year in years:
|
||||||
|
sliced, start = slice_year(data, year)
|
||||||
|
if len(sliced) < 250:
|
||||||
|
print(f" {year:>6}: insufficient data ({len(sliced)} bars)")
|
||||||
|
strategy_results["years"][str(year)] = {"trades": 0}
|
||||||
|
continue
|
||||||
|
|
||||||
|
htf_sliced = sliced.copy() if htf_tf == tf else slice_year(htf_data, year)[0]
|
||||||
|
strategy = cfg["factory"]()
|
||||||
|
_, year_log = run_backtest(strategy, sliced, htf_sliced, pair, start_after=start)
|
||||||
|
metrics = compute_metrics(year_log)
|
||||||
|
strategy_results["years"][str(year)] = metrics
|
||||||
|
|
||||||
|
print(f" {year:>6}: {metrics['trades']:>4} trades | "
|
||||||
|
f"WR {metrics['wr']:>5.1f}% | PF {metrics['pf']:>5.2f} | "
|
||||||
|
f"PnL {metrics['pnl_pips']:>+8.1f}p | "
|
||||||
|
f"DD {metrics['max_dd_pips']:>+8.1f}p | "
|
||||||
|
f"Exp {metrics['expectancy']:>+6.2f}p")
|
||||||
|
|
||||||
|
all_results[f"{name}_{pair}"] = strategy_results
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
out_path = os.path.join(RESULTS_DIR, "regime_analysis.json")
|
||||||
|
with open(out_path, "w") as f:
|
||||||
|
json.dump(all_results, f, indent=2, default=str)
|
||||||
|
print(f"\nResults saved: {out_path}")
|
||||||
|
|
||||||
|
# Summary table
|
||||||
|
print(f"\n{'='*90}")
|
||||||
|
print("REGIME SUMMARY — Per-Year Profit Factor")
|
||||||
|
print(f"{'='*90}")
|
||||||
|
print(f"{'Strategy':<20} {'Pair':<10} {'Full':>6} {'2021':>6} {'2022':>6} {'2023':>6} {'Trend':>8}")
|
||||||
|
print(f"{'-'*90}")
|
||||||
|
for key, result in all_results.items():
|
||||||
|
name_pair = key.split("_", 1)
|
||||||
|
name = result.get("pair", key)
|
||||||
|
pf_full = result.get("full", {}).get("pf", 0)
|
||||||
|
pf_years = []
|
||||||
|
for y in ["2021", "2022", "2023"]:
|
||||||
|
pf = result.get("years", {}).get(y, {}).get("pf", 0)
|
||||||
|
pf_years.append(pf)
|
||||||
|
|
||||||
|
# Trend: compare first year to last year
|
||||||
|
if pf_years[0] > 0 and pf_years[-1] > 0:
|
||||||
|
if pf_years[-1] > pf_years[0] * 1.1:
|
||||||
|
trend = "UP"
|
||||||
|
elif pf_years[-1] < pf_years[0] * 0.9:
|
||||||
|
trend = "DOWN"
|
||||||
|
else:
|
||||||
|
trend = "STABLE"
|
||||||
|
else:
|
||||||
|
trend = "N/A"
|
||||||
|
|
||||||
|
print(f"{key:<20} {result.get('pair',''):<10} "
|
||||||
|
f"{pf_full:>5.2f} {pf_years[0]:>5.2f} {pf_years[1]:>5.2f} "
|
||||||
|
f"{pf_years[2]:>5.2f} {trend:>8}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user