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,282 @@
|
||||
"""
|
||||
tests/test_analyzers.py
|
||||
Unit tests for all analyzer modules using synthetic trade data.
|
||||
"""
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from data.models import RunMetrics
|
||||
from analysis.reversal import ReversalAnalyzer
|
||||
from analysis.time_performance import TimePerformanceAnalyzer
|
||||
from analysis.entry_exit_quality import EntryExitQualityAnalyzer
|
||||
from analysis.equity_curve import EquityCurveAnalyzer
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_trade(
|
||||
net_money: float,
|
||||
mfe_pips: float = 0,
|
||||
mae_pips: float = 0,
|
||||
hour_utc: int = 10,
|
||||
session: str = "London",
|
||||
day_of_week: int = 1,
|
||||
result_class: str = None,
|
||||
duration_minutes: int = 60,
|
||||
lot_size: float = 0.1,
|
||||
net_pips: float = None,
|
||||
open_time: datetime = None,
|
||||
) -> dict:
|
||||
if result_class is None:
|
||||
result_class = "win" if net_money > 0 else ("reversal" if mfe_pips > 15 and net_money < 0 else "loss")
|
||||
if net_pips is None:
|
||||
net_pips = net_money / 100
|
||||
if open_time is None:
|
||||
open_time = datetime(2022, 1, 3, hour_utc, 0)
|
||||
return {
|
||||
"ticket": np.random.randint(100000, 999999),
|
||||
"open_time": open_time,
|
||||
"close_time": open_time + timedelta(minutes=duration_minutes),
|
||||
"direction": "buy",
|
||||
"open_price": 1900.0,
|
||||
"close_price": 1900.0 + net_pips * 0.1,
|
||||
"sl": 1880.0, "tp": 1920.0,
|
||||
"lot_size": lot_size,
|
||||
"net_money": net_money,
|
||||
"net_pips": net_pips,
|
||||
"duration_minutes": duration_minutes,
|
||||
"commission": 0.0, "swap": 0.0,
|
||||
"mfe_pips": mfe_pips, "mae_pips": mae_pips,
|
||||
"session": session, "day_of_week": day_of_week,
|
||||
"hour_utc": hour_utc, "hour_broker": (hour_utc + 2) % 24,
|
||||
"result_class": result_class,
|
||||
"mfe_capture_ratio": max(0, net_money) / max(1, mfe_pips * 10),
|
||||
"entry_quality": max(0, 1 - mae_pips / max(mfe_pips + mae_pips, 1)),
|
||||
"exit_quality": max(0, net_pips / max(mfe_pips, 1)),
|
||||
}
|
||||
|
||||
|
||||
def dummy_metrics(trades_df: pd.DataFrame, run_id: str = "test_run") -> RunMetrics:
|
||||
wins = trades_df[trades_df["net_money"] > 0]
|
||||
losses = trades_df[trades_df["net_money"] < 0]
|
||||
net = trades_df["net_money"].sum()
|
||||
gp = wins["net_money"].sum()
|
||||
gl = abs(losses["net_money"].sum())
|
||||
pf = gp / gl if gl > 0 else 99.0
|
||||
dd = abs(losses["net_money"].min()) if len(losses) > 0 else 1
|
||||
return RunMetrics(
|
||||
run_id=run_id,
|
||||
net_profit=net,
|
||||
profit_factor=pf,
|
||||
max_drawdown_abs=dd,
|
||||
max_drawdown_pct=dd / 10000,
|
||||
calmar_ratio=max(0, net / 10000) / max(0.01, dd / 10000),
|
||||
sharpe_ratio=1.2,
|
||||
total_trades=len(trades_df),
|
||||
win_rate=len(wins) / max(1, len(trades_df)),
|
||||
avg_win=gp / max(1, len(wins)),
|
||||
avg_loss=gl / max(1, len(losses)),
|
||||
recovery_factor=2.0,
|
||||
largest_loss=abs(losses["net_money"].min()) if len(losses) > 0 else 0,
|
||||
expected_payoff=net / max(1, len(trades_df)),
|
||||
)
|
||||
|
||||
|
||||
# ── Reversal Analyzer Tests ───────────────────────────────────────────────────
|
||||
|
||||
class TestReversalAnalyzer:
|
||||
|
||||
def test_detects_high_reversal_rate(self):
|
||||
"""Should find a HIGH finding when many losers had significant MFE."""
|
||||
trades = []
|
||||
# 50% of losers had MFE > 20 pips (high reversal rate)
|
||||
for _ in range(40):
|
||||
trades.append(make_trade(net_money=100, mfe_pips=30, mae_pips=5))
|
||||
for _ in range(20):
|
||||
trades.append(make_trade(net_money=-80, mfe_pips=25, mae_pips=8)) # reversals
|
||||
for _ in range(20):
|
||||
trades.append(make_trade(net_money=-80, mfe_pips=5, mae_pips=20)) # normal losers
|
||||
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = ReversalAnalyzer(mfe_threshold_pips=15, min_reversal_rate=0.30, permutation_n=50)
|
||||
findings = az.run(df, metrics, "test")
|
||||
|
||||
assert len(findings) > 0
|
||||
top = findings[0]
|
||||
assert top.severity in ("high", "medium")
|
||||
assert "InpUseTrailing" in top.suggested_params
|
||||
assert top.suggested_params["InpUseTrailing"] is True
|
||||
|
||||
def test_no_finding_when_low_reversal_rate(self):
|
||||
"""Should NOT find reversals when rate is below threshold."""
|
||||
trades = [make_trade(net_money=100, mfe_pips=20)] * 50
|
||||
trades += [make_trade(net_money=-80, mfe_pips=5)] * 20 # only small-MFE losers
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = ReversalAnalyzer(mfe_threshold_pips=15, min_reversal_rate=0.30, permutation_n=50)
|
||||
findings = az.run(df, metrics, "test")
|
||||
# Should have no reversal-rate finding (maybe only capture rate)
|
||||
reversal_findings = [f for f in findings if "reversal" in f.description.lower() and "% of losing" in f.description]
|
||||
assert len(reversal_findings) == 0
|
||||
|
||||
|
||||
# ── Time Performance Tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestTimePerformanceAnalyzer:
|
||||
|
||||
def test_detects_bad_hour_window(self):
|
||||
"""Should flag a consistent loss at hour 14–15 UTC."""
|
||||
trades = []
|
||||
# Good trades at most hours
|
||||
for h in [8, 9, 10, 11, 12, 13]:
|
||||
for _ in range(12):
|
||||
trades.append(make_trade(net_money=100, hour_utc=h))
|
||||
# Consistently losing trades at 14–15 UTC
|
||||
for h in [14, 15]:
|
||||
for _ in range(15):
|
||||
trades.append(make_trade(net_money=-150, hour_utc=h))
|
||||
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = TimePerformanceAnalyzer(z_score_threshold=-1.0, min_bucket_trades=8, permutation_n=200)
|
||||
findings = az.run(df, metrics, "test")
|
||||
|
||||
hour_findings = [f for f in findings if "UTC" in f.description and "14" in f.description]
|
||||
assert len(hour_findings) > 0
|
||||
|
||||
def test_no_finding_for_uniform_performance(self):
|
||||
"""No time-based finding when performance is uniform across hours."""
|
||||
trades = []
|
||||
rng = np.random.default_rng(42)
|
||||
for h in range(8, 20):
|
||||
for _ in range(12):
|
||||
pnl = float(rng.normal(50, 20))
|
||||
trades.append(make_trade(net_money=pnl, hour_utc=h))
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = TimePerformanceAnalyzer(z_score_threshold=-2.0, min_bucket_trades=8, permutation_n=200)
|
||||
findings = az.run(df, metrics, "test")
|
||||
# May or may not find something; just verify it runs without error
|
||||
assert isinstance(findings, list)
|
||||
|
||||
|
||||
# ── Entry/Exit Quality Tests ──────────────────────────────────────────────────
|
||||
|
||||
class TestEntryExitQualityAnalyzer:
|
||||
|
||||
def test_detects_good_entry_poor_exit(self):
|
||||
"""When entries are good but exits capture little of MFE."""
|
||||
trades = []
|
||||
for _ in range(60):
|
||||
# Small MAE (good entry), large MFE but poor capture
|
||||
trades.append(make_trade(
|
||||
net_money=20, mfe_pips=50, mae_pips=3,
|
||||
net_pips=2, lot_size=0.1
|
||||
))
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = EntryExitQualityAnalyzer(poor_exit_threshold=0.60, poor_entry_threshold=0.40)
|
||||
findings = az.run(df, metrics, "test")
|
||||
action_findings = [f for f in findings if "diagnosis" in f.evidence and
|
||||
f.evidence["diagnosis"] == "good_entry_poor_exit"]
|
||||
assert len(action_findings) > 0
|
||||
|
||||
def test_no_findings_for_healthy_trades(self):
|
||||
"""Should not flag anything when both entry and exit quality are high."""
|
||||
trades = []
|
||||
for _ in range(60):
|
||||
trades.append(make_trade(
|
||||
net_money=80, mfe_pips=100, mae_pips=5,
|
||||
net_pips=80, lot_size=0.1
|
||||
))
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = EntryExitQualityAnalyzer(poor_exit_threshold=0.55, poor_entry_threshold=0.40)
|
||||
findings = az.run(df, metrics, "test")
|
||||
action_findings = [f for f in findings if f.severity in ("high", "medium")]
|
||||
assert len(action_findings) == 0
|
||||
|
||||
|
||||
# ── Equity Curve Tests ────────────────────────────────────────────────────────
|
||||
|
||||
class TestEquityCurveAnalyzer:
|
||||
|
||||
def test_detects_loss_clusters(self):
|
||||
"""Should flag a sequence of 5+ consecutive losses."""
|
||||
trades = []
|
||||
base_time = datetime(2022, 1, 3, 10, 0)
|
||||
# Wins, then a cluster of losses, then more wins
|
||||
for i in range(30):
|
||||
trades.append(make_trade(net_money=100, open_time=base_time + timedelta(hours=i)))
|
||||
for i in range(30, 37): # 7 consecutive losses
|
||||
trades.append(make_trade(net_money=-150, open_time=base_time + timedelta(hours=i)))
|
||||
for i in range(37, 60):
|
||||
trades.append(make_trade(net_money=100, open_time=base_time + timedelta(hours=i)))
|
||||
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = EquityCurveAnalyzer(cluster_min_length=5)
|
||||
findings = az.run(df, metrics, "test")
|
||||
cluster_findings = [f for f in findings if "cluster" in f.description.lower()]
|
||||
assert len(cluster_findings) > 0
|
||||
|
||||
def test_detects_high_flatness(self):
|
||||
"""Should flag when equity spends most time in drawdown."""
|
||||
trades = []
|
||||
base_time = datetime(2022, 1, 3, 10, 0)
|
||||
# Pattern: win a little, lose a lot, basically always in drawdown
|
||||
for i in range(50):
|
||||
if i % 5 == 0:
|
||||
trades.append(make_trade(net_money=50, open_time=base_time + timedelta(hours=i)))
|
||||
else:
|
||||
trades.append(make_trade(net_money=-30, open_time=base_time + timedelta(hours=i)))
|
||||
df = pd.DataFrame(trades)
|
||||
metrics = dummy_metrics(df)
|
||||
az = EquityCurveAnalyzer(max_flatness=0.30)
|
||||
findings = az.run(df, metrics, "test")
|
||||
flatness_findings = [f for f in findings if "high-water" in f.description]
|
||||
assert len(flatness_findings) > 0
|
||||
|
||||
|
||||
# ── Composite Score Tests ─────────────────────────────────────────────────────
|
||||
|
||||
class TestCompositeScorer:
|
||||
|
||||
def test_score_increases_with_calmar(self):
|
||||
"""Higher Calmar should produce higher score, all else equal."""
|
||||
from scoring.composite import CompositeScorer
|
||||
|
||||
def make_metrics(calmar):
|
||||
return RunMetrics(
|
||||
run_id="t",
|
||||
net_profit=10000, profit_factor=1.5,
|
||||
max_drawdown_abs=1000, max_drawdown_pct=0.10,
|
||||
calmar_ratio=calmar, sharpe_ratio=1.2,
|
||||
total_trades=100, win_rate=0.55,
|
||||
avg_win=200, avg_loss=150,
|
||||
recovery_factor=3.0, largest_loss=500,
|
||||
expected_payoff=50,
|
||||
avg_mfe_capture=0.6, reversal_rate=0.1,
|
||||
)
|
||||
|
||||
scorer = CompositeScorer("config.yaml")
|
||||
s1 = scorer.score(make_metrics(0.5))
|
||||
s2 = scorer.score(make_metrics(1.5))
|
||||
s3 = scorer.score(make_metrics(3.0))
|
||||
assert s1 < s2 < s3
|
||||
|
||||
def test_score_zero_below_min_trades(self):
|
||||
from scoring.composite import CompositeScorer
|
||||
scorer = CompositeScorer("config.yaml")
|
||||
m = RunMetrics(
|
||||
run_id="t", net_profit=5000, profit_factor=2.0,
|
||||
max_drawdown_abs=500, max_drawdown_pct=0.05,
|
||||
calmar_ratio=2.0, sharpe_ratio=1.5,
|
||||
total_trades=10, # below min_trades (50)
|
||||
win_rate=0.6, avg_win=200, avg_loss=100,
|
||||
recovery_factor=4.0, largest_loss=200, expected_payoff=100,
|
||||
)
|
||||
assert scorer.score(m) == 0.0
|
||||
Reference in New Issue
Block a user