Initial commit: MT5 EA Optimizer v1.0
Full optimization system for LEGSTECH_EA_V2: - Flask + SocketIO live dashboard (dark premium UI) - MT5 process control (auto-kill, clean launch, retry) - HTML report parser (UTF-16 LE, 597 trades, metrics) - Pre-run validation and actionable error messages - Analysis engines: Reversal, TimePerfomance, EntryExit, EquityCurve - Composite scoring (Calmar-primary) - Mutation engine with knowledge_base.yaml - Validation gate: IS + Walk-Forward - Reports folder with HTML/CSV per run - Double-click launcher batch file
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
analysis/base.py
|
||||
Abstract base class for all analyzer modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from data.models import Finding, RunMetrics
|
||||
|
||||
|
||||
class BaseAnalyzer(ABC):
|
||||
"""
|
||||
Every analyzer receives a trades DataFrame and RunMetrics,
|
||||
and returns a list of Finding objects sorted by confidence descending.
|
||||
"""
|
||||
|
||||
name: str = "base"
|
||||
min_trades: int = 30 # refuse to analyze below this count
|
||||
run_id: str = ""
|
||||
|
||||
def run(
|
||||
self,
|
||||
trades: pd.DataFrame,
|
||||
metrics: RunMetrics,
|
||||
run_id: str,
|
||||
) -> list[Finding]:
|
||||
"""Entry point — enforces minimum trade count gate."""
|
||||
self.run_id = run_id
|
||||
if len(trades) < self.min_trades:
|
||||
return []
|
||||
return self.analyze(trades, metrics)
|
||||
|
||||
@abstractmethod
|
||||
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Implement analysis logic. Return list of findings."""
|
||||
...
|
||||
|
||||
# ── Statistical helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _confidence_from_z(self, z: float) -> float:
|
||||
"""Map a Z-score to a [0,1] confidence value using normal CDF."""
|
||||
from scipy.stats import norm
|
||||
return float(min(1.0, max(0.0, 2 * norm.cdf(abs(z)) - 1)))
|
||||
|
||||
def _permutation_pvalue(
|
||||
self,
|
||||
group_values: np.ndarray,
|
||||
all_values: np.ndarray,
|
||||
n_permutations: int = 500,
|
||||
alternative: str = "less", # 'less' = testing if group mean < overall mean
|
||||
) -> float:
|
||||
"""
|
||||
Non-parametric permutation test.
|
||||
Returns p-value: probability that observed group mean is due to chance.
|
||||
Lower p-value = more statistically significant.
|
||||
"""
|
||||
if len(group_values) == 0 or len(all_values) == 0:
|
||||
return 1.0
|
||||
|
||||
observed_stat = np.mean(group_values)
|
||||
n_group = len(group_values)
|
||||
count_extreme = 0
|
||||
|
||||
rng = np.random.default_rng(seed=42) # deterministic
|
||||
for _ in range(n_permutations):
|
||||
sample = rng.choice(all_values, size=n_group, replace=False)
|
||||
sample_stat = np.mean(sample)
|
||||
if alternative == "less" and sample_stat <= observed_stat:
|
||||
count_extreme += 1
|
||||
elif alternative == "greater" and sample_stat >= observed_stat:
|
||||
count_extreme += 1
|
||||
|
||||
return count_extreme / n_permutations
|
||||
|
||||
def _severity(self, confidence: float, impact_pnl: float, total_pnl: float) -> str:
|
||||
"""Derive severity from confidence and relative $ impact."""
|
||||
impact_fraction = abs(impact_pnl) / max(abs(total_pnl), 1)
|
||||
if confidence >= 0.80 or impact_fraction >= 0.15:
|
||||
return "high"
|
||||
elif confidence >= 0.60 or impact_fraction >= 0.07:
|
||||
return "medium"
|
||||
return "low"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
analysis/entry_exit_quality.py
|
||||
Scores trade entries and exits using MAE/MFE ratios.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from data.models import Finding, RunMetrics
|
||||
from analysis.base import BaseAnalyzer
|
||||
|
||||
|
||||
class EntryExitQualityAnalyzer(BaseAnalyzer):
|
||||
"""
|
||||
Entry/Exit Quality Scorer.
|
||||
|
||||
Uses MAE and MFE to independently assess:
|
||||
- Entry quality: how much did price move against us before moving our way?
|
||||
- Exit quality : what fraction of the available move did we capture?
|
||||
|
||||
Diagnosis matrix:
|
||||
┌─────────────┬──────────────┬──────────────────────────────────────┐
|
||||
│entry_quality│ exit_quality │ Diagnosis │
|
||||
├─────────────┼──────────────┼──────────────────────────────────────┤
|
||||
│ High │ High │ Healthy — no action │
|
||||
│ High │ Low │ Good entries, poor exits → trail/TP │
|
||||
│ Low │ High │ Bad entries, recovering → entry filt │
|
||||
│ Low │ Low │ Systematic issue → both sides broken │
|
||||
└─────────────┴──────────────┴──────────────────────────────────────┘
|
||||
"""
|
||||
|
||||
name = "entry_exit_quality"
|
||||
min_trades = 20
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
poor_exit_threshold: float = 0.55,
|
||||
poor_entry_threshold: float = 0.40,
|
||||
good_entry_threshold: float = 0.65,
|
||||
):
|
||||
self.poor_exit = poor_exit_threshold
|
||||
self.poor_entry = poor_entry_threshold
|
||||
self.good_entry = good_entry_threshold
|
||||
|
||||
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
if "mfe_pips" not in trades.columns or trades["mfe_pips"].isna().all():
|
||||
return []
|
||||
|
||||
df = trades[trades["mfe_pips"].notna() & (trades["mfe_pips"] > 0)].copy()
|
||||
if len(df) < self.min_trades:
|
||||
return []
|
||||
|
||||
# Compute quality scores if not already present
|
||||
if "exit_quality" not in df.columns or df["exit_quality"].isna().all():
|
||||
df["exit_quality"] = (df["net_pips"] / df["mfe_pips"].clip(lower=0.01)).clip(0, 1)
|
||||
if "entry_quality" not in df.columns or df["entry_quality"].isna().all():
|
||||
denom = df["mfe_pips"] + df["mae_pips"].fillna(0) + 0.01
|
||||
df["entry_quality"] = (1 - df["mae_pips"].fillna(0) / denom).clip(0, 1)
|
||||
|
||||
mean_exit = float(df["exit_quality"].mean())
|
||||
mean_entry = float(df["entry_quality"].mean())
|
||||
|
||||
findings = []
|
||||
|
||||
# ── Case 1: Good entries, poor exits ──────────────────────────────
|
||||
if mean_exit < self.poor_exit and mean_entry >= self.good_entry:
|
||||
z = (self.poor_exit - mean_exit) / max(0.01, df["exit_quality"].std())
|
||||
confidence = min(0.95, self._confidence_from_z(z))
|
||||
potential = float((df["mfe_pips"] - df["net_pips"].clip(lower=0)).clip(lower=0).mean())
|
||||
impact = potential * len(df) * 0.1 # rough dollar estimate
|
||||
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Good entries (quality={mean_entry:.2f}) but poor exits "
|
||||
f"(quality={mean_exit:.2f}). "
|
||||
f"Capturing only {mean_exit*100:.0f}% of available MFE. "
|
||||
f"Consider trailing stop or tighter TP."
|
||||
),
|
||||
severity=self._severity(confidence, impact, metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={
|
||||
"InpUseTrailing": True,
|
||||
"InpTrailStartPips": round(float(df["mfe_pips"].quantile(0.30)), 1),
|
||||
"InpTrailStepPips": 10.0,
|
||||
},
|
||||
evidence={
|
||||
"mean_entry_quality": round(mean_entry, 4),
|
||||
"mean_exit_quality": round(mean_exit, 4),
|
||||
"diagnosis": "good_entry_poor_exit",
|
||||
"sample_size": len(df),
|
||||
},
|
||||
))
|
||||
|
||||
# ── Case 2: Poor entries ───────────────────────────────────────────
|
||||
elif mean_entry < self.poor_entry:
|
||||
z = (self.poor_entry - mean_entry) / max(0.01, df["entry_quality"].std())
|
||||
confidence = min(0.95, self._confidence_from_z(z))
|
||||
# Trades with high MAE but positive result still suggest entry timing issue
|
||||
high_mae_losers = df[(df["mae_pips"] > df["mfe_pips"]) & (df["net_money"] < 0)]
|
||||
impact = abs(float(high_mae_losers["net_money"].sum()))
|
||||
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Poor entry quality ({mean_entry:.2f}): "
|
||||
f"significant adverse move before trades become profitable. "
|
||||
f"{len(high_mae_losers)} trades had MAE > MFE and closed at a loss. "
|
||||
f"Consider tightening entry filters (ATR, EMA slope, score gate)."
|
||||
),
|
||||
severity=self._severity(confidence, impact, metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={
|
||||
"InpUseATRFilter": True, # placeholder name; map to actual param
|
||||
"InpATRMultiplier": round(float(df["mae_pips"].quantile(0.70)) / 100, 1),
|
||||
"InpMinScore": 9, # tighten quality gate
|
||||
},
|
||||
evidence={
|
||||
"mean_entry_quality": round(mean_entry, 4),
|
||||
"mean_exit_quality": round(mean_exit, 4),
|
||||
"diagnosis": "poor_entry",
|
||||
"high_mae_loser_count": int(len(high_mae_losers)),
|
||||
"sample_size": len(df),
|
||||
},
|
||||
))
|
||||
|
||||
# ── Case 3: Both broken ────────────────────────────────────────────
|
||||
elif mean_exit < self.poor_exit and mean_entry < self.poor_entry:
|
||||
confidence = 0.75
|
||||
impact = abs(metrics.net_profit) * 0.5 # rough
|
||||
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Both entry ({mean_entry:.2f}) and exit ({mean_exit:.2f}) quality are poor. "
|
||||
f"This suggests a systematic issue with the strategy logic. "
|
||||
f"Consider testing a different BotMode or EntryMode."
|
||||
),
|
||||
severity="high",
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={"InpBotMode": 2}, # conservative mode
|
||||
evidence={
|
||||
"mean_entry_quality": round(mean_entry, 4),
|
||||
"mean_exit_quality": round(mean_exit, 4),
|
||||
"diagnosis": "both_broken",
|
||||
"sample_size": len(df),
|
||||
},
|
||||
))
|
||||
|
||||
# ── Always report summary stats as a LOW finding for visibility ───
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Entry quality: {mean_entry:.2f} | Exit quality: {mean_exit:.2f} | "
|
||||
f"Sample: {len(df)} trades with MFE data."
|
||||
),
|
||||
severity="low",
|
||||
confidence=0.99,
|
||||
impact_estimate_pnl=0.0,
|
||||
suggested_params={},
|
||||
evidence={
|
||||
"mean_entry_quality": round(mean_entry, 4),
|
||||
"mean_exit_quality": round(mean_exit, 4),
|
||||
"sample_size": len(df),
|
||||
"diagnosis": "summary",
|
||||
},
|
||||
))
|
||||
|
||||
return sorted(findings, key=lambda f: f.confidence, reverse=True)
|
||||
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
analysis/equity_curve.py
|
||||
Analyzes equity curve shape: drawdown clusters, flatness, recovery efficiency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import linregress
|
||||
|
||||
from data.models import Finding, RunMetrics
|
||||
from analysis.base import BaseAnalyzer
|
||||
|
||||
|
||||
class EquityCurveAnalyzer(BaseAnalyzer):
|
||||
"""
|
||||
Equity Curve Shape Analyzer.
|
||||
|
||||
Metrics computed:
|
||||
- Flatness score : % of trades where equity is below its running high-water mark
|
||||
- Recovery time : average trades needed to recover from a drawdown
|
||||
- Equity R² : linearity of cumulative PnL (high = consistent growth)
|
||||
- Loss clusters : sequences of consecutive losses (≥ N in a row)
|
||||
"""
|
||||
|
||||
name = "equity_curve"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_flatness: float = 0.50,
|
||||
min_r_squared: float = 0.70,
|
||||
cluster_min_length: int = 3,
|
||||
):
|
||||
self.max_flatness = max_flatness
|
||||
self.min_r_sq = min_r_squared
|
||||
self.cluster_min = cluster_min_length
|
||||
|
||||
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
if "net_money" not in trades.columns or len(trades) < self.min_trades:
|
||||
return []
|
||||
|
||||
df = trades.sort_values("open_time").reset_index(drop=True)
|
||||
df["cumulative_pnl"] = df["net_money"].cumsum()
|
||||
df["hwm"] = df["cumulative_pnl"].cummax() # high-water mark
|
||||
|
||||
findings = []
|
||||
findings += self._check_flatness(df, metrics)
|
||||
findings += self._check_r_squared(df, metrics)
|
||||
findings += self._check_loss_clusters(df, metrics)
|
||||
return sorted(findings, key=lambda f: f.confidence, reverse=True)
|
||||
|
||||
# ── Flatness ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_flatness(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""% of time equity is below its high-water mark."""
|
||||
below_hwm = (df["cumulative_pnl"] < df["hwm"]).sum()
|
||||
flatness = below_hwm / len(df)
|
||||
|
||||
if flatness <= self.max_flatness:
|
||||
return []
|
||||
|
||||
confidence = min(0.90, (flatness - self.max_flatness) * 4)
|
||||
impact = abs(metrics.net_profit) * (flatness - self.max_flatness)
|
||||
|
||||
# Compute average recovery length (trades to get back to HWM)
|
||||
recovery_lengths = self._compute_recovery_lengths(df)
|
||||
avg_recovery = float(np.mean(recovery_lengths)) if recovery_lengths else 0.0
|
||||
|
||||
return [Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Equity below high-water mark {flatness*100:.0f}% of the time "
|
||||
f"(threshold: {self.max_flatness*100:.0f}%). "
|
||||
f"Avg recovery: {avg_recovery:.0f} trades. "
|
||||
f"Consider reducing risk or adding drawdown pause logic."
|
||||
),
|
||||
severity=self._severity(confidence, impact, metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={
|
||||
"InpRiskPercent": round(max(0.5, metrics.net_profit / 10000 * 0.75), 1),
|
||||
"InpMaxDailyLossPct": 2.0,
|
||||
},
|
||||
evidence={
|
||||
"flatness_score": round(float(flatness), 4),
|
||||
"avg_recovery": round(avg_recovery, 1),
|
||||
"below_hwm_count": int(below_hwm),
|
||||
"total_trades": len(df),
|
||||
},
|
||||
)]
|
||||
|
||||
def _compute_recovery_lengths(self, df: pd.DataFrame) -> list[int]:
|
||||
"""Count how many trades it takes to recover from each drawdown trough."""
|
||||
lengths = []
|
||||
in_dd = False
|
||||
count = 0
|
||||
for _, row in df.iterrows():
|
||||
below = row["cumulative_pnl"] < row["hwm"]
|
||||
if below and not in_dd:
|
||||
in_dd = True
|
||||
count = 1
|
||||
elif below and in_dd:
|
||||
count += 1
|
||||
elif not below and in_dd:
|
||||
lengths.append(count)
|
||||
in_dd = False
|
||||
count = 0
|
||||
return lengths
|
||||
|
||||
# ── R² linearity ──────────────────────────────────────────────────────────
|
||||
|
||||
def _check_r_squared(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Linear regression on cumulative PnL; low R² = high variance / choppy growth."""
|
||||
x = np.arange(len(df))
|
||||
y = df["cumulative_pnl"].values
|
||||
|
||||
try:
|
||||
slope, intercept, r_value, p_value, _ = linregress(x, y)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
r_sq = r_value ** 2
|
||||
if r_sq >= self.min_r_sq or slope <= 0:
|
||||
return []
|
||||
|
||||
confidence = min(0.85, (self.min_r_sq - r_sq) * 3)
|
||||
|
||||
return [Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Equity curve linearity R²={r_sq:.2f} (threshold {self.min_r_sq:.2f}). "
|
||||
f"High variance in growth pattern — inconsistent performance. "
|
||||
f"May indicate regime sensitivity or scattered trade timing."
|
||||
),
|
||||
severity="medium" if r_sq < 0.50 else "low",
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=0.0,
|
||||
suggested_params={},
|
||||
evidence={
|
||||
"r_squared": round(r_sq, 4),
|
||||
"slope": round(float(slope), 4),
|
||||
"p_value": round(float(p_value), 4),
|
||||
},
|
||||
)]
|
||||
|
||||
# ── Loss clusters ─────────────────────────────────────────────────────────
|
||||
|
||||
def _check_loss_clusters(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Find sequences of ≥ N consecutive losing trades."""
|
||||
clusters = []
|
||||
streak = 0
|
||||
start_idx = None
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
if row["net_money"] < 0:
|
||||
if streak == 0:
|
||||
start_idx = idx
|
||||
streak += 1
|
||||
else:
|
||||
if streak >= self.cluster_min:
|
||||
cluster_df = df.loc[start_idx:idx - 1]
|
||||
clusters.append({
|
||||
"length": streak,
|
||||
"total_pnl": float(cluster_df["net_money"].sum()),
|
||||
"start_time": str(df.loc[start_idx, "open_time"]) if "open_time" in df.columns else "?",
|
||||
})
|
||||
streak = 0
|
||||
|
||||
# Handle cluster at end of data
|
||||
if streak >= self.cluster_min and start_idx is not None:
|
||||
cluster_df = df.loc[start_idx:]
|
||||
clusters.append({
|
||||
"length": streak,
|
||||
"total_pnl": float(cluster_df["net_money"].sum()),
|
||||
"start_time": str(df.loc[start_idx, "open_time"]) if "open_time" in df.columns else "?",
|
||||
})
|
||||
|
||||
if not clusters:
|
||||
return []
|
||||
|
||||
max_cluster = max(c["length"] for c in clusters)
|
||||
total_cluster_loss = sum(c["total_pnl"] for c in clusters if c["total_pnl"] < 0)
|
||||
confidence = min(0.85, len(clusters) * 0.12 + max_cluster * 0.05)
|
||||
|
||||
return [Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Found {len(clusters)} loss cluster(s) of ≥ {self.cluster_min} consecutive losses. "
|
||||
f"Worst streak: {max_cluster} trades. "
|
||||
f"Total cluster losses: ${total_cluster_loss:.0f}."
|
||||
),
|
||||
severity=self._severity(confidence, abs(total_cluster_loss), metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=abs(total_cluster_loss),
|
||||
suggested_params={
|
||||
"InpMaxDailyLossPct": 2.0,
|
||||
"InpMaxTradesPerDay": 3,
|
||||
},
|
||||
evidence={
|
||||
"cluster_count": len(clusters),
|
||||
"max_streak": max_cluster,
|
||||
"total_cluster_loss": round(total_cluster_loss, 2),
|
||||
"clusters": clusters[:5], # keep top 5 for display
|
||||
},
|
||||
)]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
analysis/reversal.py
|
||||
Detects trades that went into significant profit but ultimately closed as losses.
|
||||
Measures profit giveback and proposes trailing / TP tightening adjustments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from data.models import Finding, RunMetrics
|
||||
from analysis.base import BaseAnalyzer
|
||||
|
||||
|
||||
class ReversalAnalyzer(BaseAnalyzer):
|
||||
"""
|
||||
Profit Reversal Detector.
|
||||
|
||||
A "reversal" trade is one that:
|
||||
- Closed at a loss (net_money < 0)
|
||||
- Had MFE >= threshold pips (i.e. was at significant unrealised profit at some point)
|
||||
|
||||
Also flags trades that won but captured less than X% of their MFE (partial giveback).
|
||||
"""
|
||||
|
||||
name = "reversal"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mfe_threshold_pips: float = 15.0,
|
||||
min_reversal_rate: float = 0.15,
|
||||
poor_capture_rate: float = 0.55,
|
||||
permutation_n: int = 500,
|
||||
):
|
||||
self.mfe_threshold = mfe_threshold_pips
|
||||
self.min_reversal_rate = min_reversal_rate
|
||||
self.poor_capture_rate = poor_capture_rate
|
||||
self.permutation_n = permutation_n
|
||||
|
||||
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
findings = []
|
||||
|
||||
has_mfe = "mfe_pips" in trades.columns and trades["mfe_pips"].notna().sum() > 10
|
||||
|
||||
if has_mfe:
|
||||
findings += self._check_reversals(trades, metrics)
|
||||
findings += self._check_capture_rate(trades, metrics)
|
||||
else:
|
||||
# Without MFE, do a simpler check using result_class if available
|
||||
if "result_class" in trades.columns:
|
||||
findings += self._check_result_classes(trades, metrics)
|
||||
|
||||
return sorted(findings, key=lambda f: f.confidence, reverse=True)
|
||||
|
||||
# ── Reversal rate check ───────────────────────────────────────────────────
|
||||
|
||||
def _check_reversals(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Find losing trades that had significant unrealised profit."""
|
||||
losers = df[df["net_money"] < 0]
|
||||
if len(losers) == 0:
|
||||
return []
|
||||
|
||||
reversals = losers[losers["mfe_pips"] >= self.mfe_threshold]
|
||||
rate = len(reversals) / len(losers)
|
||||
|
||||
if rate < self.min_reversal_rate or len(reversals) < 5:
|
||||
return []
|
||||
|
||||
avg_giveback_pips = float(reversals["mfe_pips"].mean())
|
||||
total_lost = float(losers["net_money"].sum())
|
||||
reversal_lost = float(reversals["net_money"].sum())
|
||||
|
||||
# Permutation test: is the reversal rate unusually high?
|
||||
all_mfe = df[df["net_money"] < 0]["mfe_pips"].dropna().values
|
||||
if len(all_mfe) > 0:
|
||||
p_val = self._permutation_pvalue(
|
||||
reversals["mfe_pips"].values, all_mfe,
|
||||
n_permutations=self.permutation_n, alternative="greater"
|
||||
)
|
||||
else:
|
||||
p_val = 0.01 # assume significant
|
||||
|
||||
confidence = max(0.0, min(1.0, 1 - p_val))
|
||||
impact_pnl = abs(reversal_lost) # upper bound on recoverable PnL
|
||||
|
||||
# Compute median reversal time for trailing stop suggestion
|
||||
if "duration_minutes" in df.columns:
|
||||
median_dur = float(reversals["duration_minutes"].median())
|
||||
else:
|
||||
median_dur = 0
|
||||
|
||||
# Compute 25th percentile of MFE at reversal — suggest TrailStart at this level
|
||||
mfe_p25 = float(reversals["mfe_pips"].quantile(0.25))
|
||||
|
||||
suggested = {
|
||||
"InpUseTrailing": True,
|
||||
"InpTrailStartPips": round(max(10.0, mfe_p25 * 0.85), 1),
|
||||
"InpTrailStepPips": 10.0,
|
||||
}
|
||||
|
||||
return [Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"{rate*100:.0f}% of losing trades had MFE ≥ {self.mfe_threshold:.0f} pips "
|
||||
f"before reversing ({len(reversals)} trades). "
|
||||
f"Avg giveback: {avg_giveback_pips:.1f} pips. "
|
||||
f"Estimated recoverable PnL: ${impact_pnl:.0f}."
|
||||
),
|
||||
severity=self._severity(confidence, impact_pnl, metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact_pnl,
|
||||
suggested_params=suggested,
|
||||
evidence={
|
||||
"reversal_count": len(reversals),
|
||||
"reversal_rate": round(rate, 4),
|
||||
"avg_giveback_pips": round(avg_giveback_pips, 2),
|
||||
"mfe_p25": round(mfe_p25, 2),
|
||||
"median_duration_min": round(median_dur, 0),
|
||||
"p_value": round(p_val, 4),
|
||||
},
|
||||
)]
|
||||
|
||||
# ── MFE Capture rate check ────────────────────────────────────────────────
|
||||
|
||||
def _check_capture_rate(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Check if winning trades are capturing enough of their MFE."""
|
||||
winners = df[(df["net_money"] > 0) & df["mfe_pips"].notna() & (df["mfe_pips"] > 0)]
|
||||
if len(winners) < 10:
|
||||
return []
|
||||
|
||||
if "exit_quality" not in df.columns or df["exit_quality"].isna().all():
|
||||
winners = winners.copy()
|
||||
winners["exit_quality"] = winners["net_pips"] / winners["mfe_pips"].clip(lower=0.01)
|
||||
|
||||
mean_capture = float(winners["exit_quality"].mean())
|
||||
if mean_capture >= self.poor_capture_rate:
|
||||
return []
|
||||
|
||||
potential_gain = float(
|
||||
(winners["mfe_pips"] - winners["net_pips"]).clip(lower=0).mean()
|
||||
) * float(winners["lot_size"].mean()) * 100 # rough $
|
||||
|
||||
confidence = self._confidence_from_z(
|
||||
(self.poor_capture_rate - mean_capture) / max(0.01, winners["exit_quality"].std())
|
||||
)
|
||||
|
||||
return [Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Winners capture only {mean_capture*100:.0f}% of their MFE on average. "
|
||||
f"Potential gain with better exits: ~${potential_gain*len(winners):.0f}."
|
||||
),
|
||||
severity=self._severity(confidence, potential_gain * len(winners), metrics.net_profit),
|
||||
confidence=min(0.95, confidence),
|
||||
impact_estimate_pnl=potential_gain * len(winners),
|
||||
suggested_params={
|
||||
"InpUseTrailing": True,
|
||||
"InpTrailStartPips": round(float(winners["mfe_pips"].quantile(0.30)), 1),
|
||||
},
|
||||
evidence={
|
||||
"mean_capture_ratio": round(mean_capture, 4),
|
||||
"winner_count": len(winners),
|
||||
},
|
||||
)]
|
||||
|
||||
# ── Fallback: result_class based ─────────────────────────────────────────
|
||||
|
||||
def _check_result_classes(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Simple reversal check using pre-classified result_class column."""
|
||||
reversals = df[df["result_class"] == "reversal"]
|
||||
losers = df[df["net_money"] < 0]
|
||||
if len(losers) == 0 or len(reversals) == 0:
|
||||
return []
|
||||
|
||||
rate = len(reversals) / len(losers)
|
||||
if rate < self.min_reversal_rate:
|
||||
return []
|
||||
|
||||
return [Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=f"{rate*100:.0f}% of losers classified as reversals (MFE-based).",
|
||||
severity="medium",
|
||||
confidence=0.65,
|
||||
impact_estimate_pnl=abs(float(reversals["net_money"].sum())),
|
||||
suggested_params={"InpUseTrailing": True},
|
||||
evidence={"reversal_rate": round(rate, 4)},
|
||||
)]
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
analysis/time_performance.py
|
||||
Analyzes trade performance by hour (UTC), session, and day of week.
|
||||
Identifies statistically significant negative-edge time windows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from data.models import Finding, RunMetrics
|
||||
from analysis.base import BaseAnalyzer
|
||||
|
||||
|
||||
class TimePerformanceAnalyzer(BaseAnalyzer):
|
||||
"""
|
||||
Session / Hour / Day-of-Week Performance Analyzer.
|
||||
|
||||
Buckets trades by time dimension and flags windows with:
|
||||
- Z-score < threshold (mean PnL very negative vs overall)
|
||||
- Statistically significant by permutation test (p < 0.10)
|
||||
- Minimum trade count (don't flag buckets with too few trades)
|
||||
"""
|
||||
|
||||
name = "time_performance"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
z_score_threshold: float = -1.5,
|
||||
min_bucket_trades: int = 10,
|
||||
permutation_n: int = 1000,
|
||||
pvalue_threshold: float = 0.10,
|
||||
):
|
||||
self.z_threshold = z_score_threshold
|
||||
self.min_bucket = min_bucket_trades
|
||||
self.perm_n = permutation_n
|
||||
self.p_threshold = pvalue_threshold
|
||||
|
||||
def analyze(self, trades: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
if "hour_utc" not in trades.columns:
|
||||
return []
|
||||
|
||||
findings = []
|
||||
findings += self._analyze_hours(trades, metrics)
|
||||
findings += self._analyze_sessions(trades, metrics)
|
||||
findings += self._analyze_days(trades, metrics)
|
||||
return sorted(findings, key=lambda f: f.confidence, reverse=True)
|
||||
|
||||
# ── Hour analysis ─────────────────────────────────────────────────────────
|
||||
|
||||
def _analyze_hours(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
"""Flag individual UTC hours with poor performance."""
|
||||
global_mean = df["net_money"].mean()
|
||||
global_std = df["net_money"].std()
|
||||
all_pnl = df["net_money"].values
|
||||
|
||||
if global_std == 0:
|
||||
return []
|
||||
|
||||
findings = []
|
||||
for hour in sorted(df["hour_utc"].dropna().unique()):
|
||||
bucket = df[df["hour_utc"] == hour]
|
||||
if len(bucket) < self.min_bucket:
|
||||
continue
|
||||
|
||||
mean_pnl = bucket["net_money"].mean()
|
||||
z = (mean_pnl - global_mean) / global_std
|
||||
|
||||
if z >= self.z_threshold:
|
||||
continue
|
||||
|
||||
# Permutation test
|
||||
p_val = self._permutation_pvalue(
|
||||
bucket["net_money"].values, all_pnl,
|
||||
n_permutations=self.perm_n, alternative="less"
|
||||
)
|
||||
if p_val >= self.p_threshold:
|
||||
continue
|
||||
|
||||
impact_pnl = abs(float(bucket[bucket["net_money"] < 0]["net_money"].sum()))
|
||||
confidence = max(0.0, min(0.97, 1 - p_val))
|
||||
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Hour {hour:02d}:00 UTC: mean PnL ${mean_pnl:.2f} "
|
||||
f"(Z={z:.2f}, {len(bucket)} trades). "
|
||||
f"Estimated negative contribution: ${impact_pnl:.0f}."
|
||||
),
|
||||
severity=self._severity(confidence, impact_pnl, metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact_pnl,
|
||||
suggested_params={}, # session filter suggestion built by aggregator
|
||||
evidence={
|
||||
"type": "hour",
|
||||
"hour_utc": int(hour),
|
||||
"mean_pnl": round(mean_pnl, 2),
|
||||
"z_score": round(z, 3),
|
||||
"trade_count": int(len(bucket)),
|
||||
"p_value": round(p_val, 4),
|
||||
},
|
||||
))
|
||||
|
||||
# Consolidate consecutive bad hours into a single window finding
|
||||
if findings:
|
||||
findings = self._consolidate_hour_findings(findings, df, metrics)
|
||||
|
||||
return findings
|
||||
|
||||
def _consolidate_hour_findings(
|
||||
self, hour_findings: list[Finding], df: pd.DataFrame, metrics: RunMetrics
|
||||
) -> list[Finding]:
|
||||
"""
|
||||
Group consecutive flagged hours into a single window finding.
|
||||
E.g. hours [14, 15, 16] → "14:00–17:00 UTC bad window"
|
||||
Returns a single consolidated finding (plus keeps top individual for detail).
|
||||
"""
|
||||
bad_hours = sorted(
|
||||
int(f.evidence["hour_utc"]) for f in hour_findings
|
||||
)
|
||||
if not bad_hours:
|
||||
return hour_findings
|
||||
|
||||
# Find contiguous groups
|
||||
groups = []
|
||||
group = [bad_hours[0]]
|
||||
for h in bad_hours[1:]:
|
||||
if h == group[-1] + 1:
|
||||
group.append(h)
|
||||
else:
|
||||
groups.append(group)
|
||||
group = [h]
|
||||
groups.append(group)
|
||||
|
||||
consolidated = []
|
||||
for g in groups:
|
||||
start_h = g[0]
|
||||
end_h = g[-1] + 1
|
||||
window = df[df["hour_utc"].between(start_h, g[-1])]
|
||||
total_pnl = float(window["net_money"].sum())
|
||||
n_trades = len(window)
|
||||
impact = abs(float(window[window["net_money"] < 0]["net_money"].sum()))
|
||||
|
||||
# Derive session filter params from window
|
||||
# Convert UTC to broker local time for session params
|
||||
broker_start = (start_h + 2) % 24 # UTC+2 (from config)
|
||||
broker_end = (end_h + 2) % 24
|
||||
|
||||
max_conf = max(f.confidence for f in hour_findings
|
||||
if f.evidence["hour_utc"] in g)
|
||||
|
||||
consolidated.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"Negative edge window {start_h:02d}:00–{end_h:02d}:00 UTC: "
|
||||
f"${total_pnl:.0f} total, {n_trades} trades. "
|
||||
f"Consider excluding this window via session filter."
|
||||
),
|
||||
severity=self._severity(max_conf, impact, metrics.net_profit),
|
||||
confidence=max_conf,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={
|
||||
"InpUseSession": True,
|
||||
# Preserve existing session start; cut end before bad window
|
||||
# These are broker-local hours
|
||||
"InpSessionEnd": (broker_start) % 24,
|
||||
},
|
||||
evidence={
|
||||
"type": "hour_window",
|
||||
"start_utc": start_h,
|
||||
"end_utc": end_h,
|
||||
"broker_start": broker_start,
|
||||
"broker_end": broker_end,
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"trade_count": n_trades,
|
||||
"hours_flagged": g,
|
||||
},
|
||||
))
|
||||
|
||||
return consolidated
|
||||
|
||||
# ── Session analysis ──────────────────────────────────────────────────────
|
||||
|
||||
def _analyze_sessions(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
if "session" not in df.columns:
|
||||
return []
|
||||
|
||||
global_mean = df["net_money"].mean()
|
||||
global_std = df["net_money"].std()
|
||||
all_pnl = df["net_money"].values
|
||||
|
||||
if global_std == 0:
|
||||
return []
|
||||
|
||||
findings = []
|
||||
for session in df["session"].dropna().unique():
|
||||
bucket = df[df["session"] == session]
|
||||
if len(bucket) < self.min_bucket:
|
||||
continue
|
||||
|
||||
mean_pnl = bucket["net_money"].mean()
|
||||
z = (mean_pnl - global_mean) / global_std
|
||||
if z >= self.z_threshold:
|
||||
continue
|
||||
|
||||
p_val = self._permutation_pvalue(
|
||||
bucket["net_money"].values, all_pnl,
|
||||
n_permutations=self.perm_n, alternative="less"
|
||||
)
|
||||
if p_val >= self.p_threshold:
|
||||
continue
|
||||
|
||||
pf = (
|
||||
bucket[bucket["net_money"] > 0]["net_money"].sum() /
|
||||
max(0.01, abs(bucket[bucket["net_money"] < 0]["net_money"].sum()))
|
||||
)
|
||||
impact = abs(float(bucket[bucket["net_money"] < 0]["net_money"].sum()))
|
||||
confidence = max(0.0, min(0.97, 1 - p_val))
|
||||
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"{session} session: PF {pf:.2f}, mean PnL ${mean_pnl:.2f} "
|
||||
f"(Z={z:.2f}, {len(bucket)} trades). Recommend excluding this session."
|
||||
),
|
||||
severity=self._severity(confidence, impact, metrics.net_profit),
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={"InpUseSession": True},
|
||||
evidence={
|
||||
"type": "session",
|
||||
"session": session,
|
||||
"profit_factor": round(float(pf), 3),
|
||||
"mean_pnl": round(mean_pnl, 2),
|
||||
"z_score": round(z, 3),
|
||||
"trade_count": int(len(bucket)),
|
||||
"p_value": round(p_val, 4),
|
||||
},
|
||||
))
|
||||
|
||||
return findings
|
||||
|
||||
# ── Day-of-week analysis ──────────────────────────────────────────────────
|
||||
|
||||
def _analyze_days(self, df: pd.DataFrame, metrics: RunMetrics) -> list[Finding]:
|
||||
if "day_of_week" not in df.columns:
|
||||
return []
|
||||
|
||||
DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
|
||||
global_mean = df["net_money"].mean()
|
||||
global_std = df["net_money"].std()
|
||||
all_pnl = df["net_money"].values
|
||||
|
||||
if global_std == 0:
|
||||
return []
|
||||
|
||||
findings = []
|
||||
for day in range(5): # 0=Mon … 4=Fri
|
||||
bucket = df[df["day_of_week"] == day]
|
||||
if len(bucket) < self.min_bucket:
|
||||
continue
|
||||
|
||||
mean_pnl = bucket["net_money"].mean()
|
||||
z = (mean_pnl - global_mean) / global_std
|
||||
if z >= self.z_threshold:
|
||||
continue
|
||||
|
||||
p_val = self._permutation_pvalue(
|
||||
bucket["net_money"].values, all_pnl,
|
||||
n_permutations=self.perm_n, alternative="less"
|
||||
)
|
||||
if p_val >= self.p_threshold:
|
||||
continue
|
||||
|
||||
impact = abs(float(bucket[bucket["net_money"] < 0]["net_money"].sum()))
|
||||
confidence = max(0.0, min(0.97, 1 - p_val))
|
||||
|
||||
findings.append(Finding(
|
||||
run_id=self.run_id,
|
||||
analyzer=self.name,
|
||||
description=(
|
||||
f"{DAY_NAMES[day]}: mean PnL ${mean_pnl:.2f} "
|
||||
f"(Z={z:.2f}, {len(bucket)} trades). "
|
||||
f"Possible day-of-week edge degradation."
|
||||
),
|
||||
severity="low", # day-level findings are informational
|
||||
confidence=confidence,
|
||||
impact_estimate_pnl=impact,
|
||||
suggested_params={},
|
||||
evidence={
|
||||
"type": "day_of_week",
|
||||
"day": DAY_NAMES[day],
|
||||
"day_index": day,
|
||||
"mean_pnl": round(mean_pnl, 2),
|
||||
"z_score": round(z, 3),
|
||||
"trade_count": int(len(bucket)),
|
||||
"p_value": round(p_val, 4),
|
||||
},
|
||||
))
|
||||
|
||||
return findings
|
||||
Reference in New Issue
Block a user