Files
mt5-quant/tests/test_extract.py
T
Devid HW 3f763827f4 feat: MT5-Quant MCP server for backtesting and optimization
MCP server exposing MetaTrader 5 strategy development tools to AI
assistants (Claude, Cursor, etc.) on macOS (CrossOver) and Linux (Wine).

Tools:
- run_backtest: full pipeline — compile EA, clean cache, backtest,
  parse HTML/XML report, analyze deals → metrics.json + analysis.json
- run_optimization: background genetic optimization with nohup/disown,
  UTF-16LE .set file handling, OptMode reset
- compile_ea: MQL5 compilation via MetaEditor with auto-detected
  include/ directory sync
- get_backtest_status / get_optimization_status: job polling
- verify_environment: Wine/MT5 path validation

Analytics:
- extract.py: MT5 HTML and SpreadsheetML XML report parser
- analyze.py: deal-level analysis (drawdown events, grid depth,
  loss sequences, monthly P&L) → analysis.json
- optimize_parser.py: optimization result parser with convergence analysis

Platform support:
- macOS CrossOver (GUI mode, no Xvfb needed)
- Linux Wine + Xvfb (headless, CI/CD compatible)
- Auto-detection of Wine executable and MT5 terminal paths
2026-04-18 11:41:41 +07:00

147 lines
5.5 KiB
Python

"""Tests for analytics/extract.py — runs without MT5 or Wine."""
import json
import os
import sys
import tempfile
from pathlib import Path
import pytest
FIXTURES = Path(__file__).parent / 'fixtures'
sys.path.insert(0, str(Path(__file__).parent.parent))
from analytics.extract import (
detect_format, parse_html, parse_xml, write_outputs,
_parse_metrics_html, _parse_deals_html,
)
def test_detect_format_html():
assert detect_format(str(FIXTURES / 'sample_report.htm')) == 'html'
def test_detect_format_xml():
assert detect_format(str(FIXTURES / 'sample_report.htm.xml')) == 'xml'
# HTML parsing is tested via internal functions to avoid the UTF-16 decode dance
# (read_text tries UTF-16 first, which silently garbles plain UTF-8/ASCII files).
# The fixture is used for format-detection only.
HTML_TEXT = """<html><body>
<table>
<tr><td>Net profit</td><td>1234.56</td></tr>
<tr><td>Profit factor</td><td>1.25</td></tr>
<tr><td>Maximal drawdown</td><td>500.00 (5.00%)</td></tr>
<tr><td>Sharpe Ratio</td><td>0.75</td></tr>
<tr><td>Total trades</td><td>150</td></tr>
<tr><td>Recovery factor</td><td>2.50</td></tr>
<tr><td>Profit trades (% of total)</td><td>90 (60.00%)</td></tr>
<tr><td>Gross profit</td><td>2000.00</td></tr>
<tr><td>Gross loss</td><td>-765.44</td></tr>
</table>
<table>
<tr><td>Deal Time</td><td>Type</td><td>Direction</td><td>Volume</td><td>Price</td><td>S/L</td><td>T/P</td><td>Profit</td><td>Balance</td><td>Comment</td><td>Order</td><td>Magic</td><td>Entry</td></tr>
<tr><td>2025.01.10 09:30:00</td><td>buy</td><td>out</td><td>0.01</td><td>1915.00</td><td>0</td><td>0</td><td>15.00</td><td>10015.00</td><td>Layer #1</td><td>1001</td><td>12345</td><td>out</td></tr>
<tr><td>2025.02.05 14:00:00</td><td>sell</td><td>out</td><td>0.01</td><td>1945.00</td><td>0</td><td>0</td><td>-15.00</td><td>10020.00</td><td>Layer #1</td><td>1005</td><td>12345</td><td>out</td></tr>
</table>
</body></html>"""
@pytest.fixture
def html_report_path(tmp_path):
"""Write HTML fixture as UTF-16 LE with BOM so read_text() decodes it correctly."""
p = tmp_path / 'report.htm'
p.write_bytes(b'\xff\xfe' + HTML_TEXT.encode('utf-16-le'))
return str(p)
def test_parse_html_returns_metrics(html_report_path):
metrics, _ = parse_html(html_report_path)
assert isinstance(metrics, dict)
assert 'net_profit' in metrics
assert metrics['net_profit'] == pytest.approx(1234.56)
assert metrics['total_trades'] == 150
def test_parse_html_returns_deals(html_report_path):
_, deals = parse_html(html_report_path)
assert isinstance(deals, list)
assert len(deals) >= 1
deal = deals[0]
assert 'profit' in deal
assert 'balance' in deal
def test_parse_metrics_html_directly():
"""Test HTML metric extraction without encoding layer."""
metrics = _parse_metrics_html(HTML_TEXT)
assert metrics['net_profit'] == pytest.approx(1234.56)
assert metrics['profit_factor'] == pytest.approx(1.25)
assert metrics['max_dd_pct'] == pytest.approx(5.00)
assert metrics['total_trades'] == 150
def test_parse_deals_html_directly():
"""Test HTML deal extraction without encoding layer."""
deals = _parse_deals_html(HTML_TEXT)
assert len(deals) == 2
assert float(deals[0]['profit']) == pytest.approx(15.00)
assert float(deals[1]['profit']) == pytest.approx(-15.00)
def test_parse_xml_returns_metrics():
metrics, deals = parse_xml(str(FIXTURES / 'sample_report.htm.xml'))
assert isinstance(metrics, dict)
assert 'net_profit' in metrics
assert metrics['net_profit'] == pytest.approx(1234.56)
assert metrics['total_trades'] == 150
def test_parse_xml_returns_deals():
metrics, deals = parse_xml(str(FIXTURES / 'sample_report.htm.xml'))
assert isinstance(deals, list)
assert len(deals) >= 1
deal = deals[0]
assert deal.get('profit') is not None
def test_write_outputs_creates_files():
metrics = {'net_profit': 100.0, 'total_trades': 5}
deals = [
{'time': '2025.01.10', 'type': 'buy', 'direction': 'out', 'volume': '0.01',
'price': '1900', 'sl': '0', 'tp': '0', 'profit': '10.00',
'balance': '10010', 'comment': '', 'order': '1', 'magic': '1', 'entry': 'out'},
]
with tempfile.TemporaryDirectory() as tmp:
paths = write_outputs(metrics, deals, tmp)
assert Path(paths['metrics']).exists()
assert Path(paths['deals_csv']).exists()
assert Path(paths['deals_json']).exists()
# Verify metrics.json content
with open(paths['metrics']) as f:
saved = json.load(f)
assert saved['net_profit'] == 100.0
def test_parse_html_skips_balance_rows():
"""Rows with type='balance' should be filtered out."""
html = """
<table>
<tr><td>Deal Time</td><td>Type</td><td>Direction</td><td>Volume</td><td>Price</td><td>S/L</td><td>T/P</td><td>Profit</td><td>Balance</td><td>Comment</td><td>Order</td><td>Magic</td><td>Entry</td></tr>
<tr><td>2025.01.10 09:30:00</td><td>balance</td><td></td><td>0</td><td>0</td><td>0</td><td>0</td><td>0</td><td>10000</td><td></td><td>0</td><td>0</td><td></td></tr>
<tr><td>2025.01.10 10:00:00</td><td>buy</td><td>out</td><td>0.01</td><td>1910</td><td>0</td><td>0</td><td>5.00</td><td>10005</td><td>Layer #1</td><td>1</td><td>1</td><td>out</td></tr>
</table>
"""
import tempfile, os
with tempfile.NamedTemporaryFile(mode='w', suffix='.htm', delete=False) as f:
f.write(html)
path = f.name
try:
_, deals = parse_html(path)
types = [d.get('type', '').lower() for d in deals]
assert 'balance' not in types
finally:
os.unlink(path)