Files
Apex_AI_MT5_EA_Optimizer/tests/test_analyzers.py
T
LEGSTECH Optimizer 7a3e13a734 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
2026-04-13 02:28:09 +00:00

283 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 1415 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 1415 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