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
This commit is contained in:
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
time,type,direction,volume,price,sl,tp,profit,balance,comment,order,magic,entry
|
||||
2025.01.10 09:00:00,buy,in,0.01,1900.00,1880.00,1920.00,0.0,10000.0,Layer #1,1001,12345,in
|
||||
2025.01.10 09:30:00,buy,out,0.01,1915.00,0.0,0.0,15.00,10015.0,Layer #1,1001,12345,out
|
||||
2025.01.15 10:00:00,sell,in,0.01,1910.00,1930.00,1890.00,0.0,10015.0,Layer #1,1002,12345,in
|
||||
2025.01.15 10:45:00,sell,out,0.01,1920.00,0.0,0.0,-10.00,10005.0,Layer #1,1002,12345,out
|
||||
2025.01.20 14:00:00,buy,in,0.02,1905.00,1880.00,1935.00,0.0,10005.0,Layer #2,1003,12345,in
|
||||
2025.01.21 09:00:00,buy,in,0.04,1895.00,1870.00,1925.00,0.0,10005.0,Layer #3,1004,12345,in
|
||||
2025.01.21 15:30:00,buy,out,0.06,1925.00,0.0,0.0,30.00,10035.0,Layer #3,1003,12345,out
|
||||
2025.02.05 10:00:00,sell,in,0.01,1930.00,1950.00,1910.00,0.0,10035.0,Layer #1,1005,12345,in
|
||||
2025.02.05 14:00:00,sell,out,0.01,1945.00,0.0,0.0,-15.00,10020.0,Layer #1,1005,12345,out
|
||||
2025.02.12 11:00:00,buy,in,0.01,1920.00,1900.00,1940.00,0.0,10020.0,Layer #1,1006,12345,in
|
||||
2025.02.12 16:00:00,buy,out,0.01,1938.00,0.0,0.0,18.00,10038.0,Layer #1,1006,12345,out
|
||||
2025.01.06 02:30:00,buy,in,0.01,1885.00,1865.00,1905.00,0.0,10038.0,Layer #1,2001,23456,in
|
||||
2025.01.06 03:15:00,buy,out,0.01,1898.00,0.0,0.0,12.00,10050.0,Layer #1 TP,2001,23456,out
|
||||
2025.01.07 20:00:00,sell,in,0.01,1910.00,1930.00,1890.00,0.0,10050.0,Layer #1,2002,23456,in
|
||||
2025.01.07 21:30:00,sell,out,0.01,1918.00,0.0,0.0,-8.00,10042.0,Layer #1 cutloss,2002,23456,out
|
||||
2025.01.08 09:30:00,buy,in,0.01,1892.00,1872.00,1912.00,0.0,10042.0,Layer #1,2003,23456,in
|
||||
2025.01.08 10:20:00,buy,out,0.01,1905.00,0.0,0.0,20.00,10062.0,Layer #1,2003,23456,out
|
||||
2025.01.13 08:00:00,sell,in,0.01,1925.00,1945.00,1905.00,0.0,10062.0,Layer #1,2004,23456,in
|
||||
2025.01.13 08:01:00,sell,in,0.02,1928.00,1948.00,1908.00,0.0,10062.0,Layer #2,2005,23456,in
|
||||
2025.01.13 08:02:00,sell,in,0.04,1931.00,1951.00,1911.00,0.0,10062.0,Layer #3,2006,23456,in
|
||||
2025.01.13 09:30:00,sell,out,0.07,1905.00,0.0,0.0,-45.00,10017.0,Layer #3 cutloss,2004,23456,out
|
||||
2025.01.16 15:00:00,buy,in,0.01,1900.00,1880.00,1920.00,0.0,10017.0,Layer #1,2007,23456,in
|
||||
2025.01.16 17:30:00,buy,out,0.01,1915.00,0.0,0.0,22.00,10039.0,Layer #1,2007,23456,out
|
||||
|
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
<html>
|
||||
<head><title>Strategy Tester Report</title></head>
|
||||
<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.01.15 10:45:00</td><td>sell</td><td>out</td><td>0.01</td><td>1920.00</td><td>0</td><td>0</td><td>-10.00</td><td>10005.00</td><td>Layer #1</td><td>1002</td><td>12345</td><td>out</td></tr>
|
||||
<tr><td>2025.01.21 15:30:00</td><td>buy</td><td>out</td><td>0.06</td><td>1925.00</td><td>0</td><td>0</td><td>30.00</td><td>10035.00</td><td>Layer #3</td><td>1003</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>
|
||||
<tr><td>2025.02.12 16:00:00</td><td>buy</td><td>out</td><td>0.01</td><td>1938.00</td><td>0</td><td>0</td><td>18.00</td><td>10038.00</td><td>Layer #1</td><td>1006</td><td>12345</td><td>out</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+88
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
||||
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||
<Worksheet ss:Name="Report">
|
||||
<Table>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">Net profit</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1234.56</Data></Cell>
|
||||
</Row>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">Profit factor</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1.25</Data></Cell>
|
||||
</Row>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">Sharpe Ratio</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0.75</Data></Cell>
|
||||
</Row>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">Total trades</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">150</Data></Cell>
|
||||
</Row>
|
||||
</Table>
|
||||
</Worksheet>
|
||||
<Worksheet ss:Name="Deals">
|
||||
<Table>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">time</Data></Cell>
|
||||
<Cell><Data ss:Type="String">type</Data></Cell>
|
||||
<Cell><Data ss:Type="String">direction</Data></Cell>
|
||||
<Cell><Data ss:Type="String">volume</Data></Cell>
|
||||
<Cell><Data ss:Type="String">price</Data></Cell>
|
||||
<Cell><Data ss:Type="String">sl</Data></Cell>
|
||||
<Cell><Data ss:Type="String">tp</Data></Cell>
|
||||
<Cell><Data ss:Type="String">profit</Data></Cell>
|
||||
<Cell><Data ss:Type="String">balance</Data></Cell>
|
||||
<Cell><Data ss:Type="String">comment</Data></Cell>
|
||||
<Cell><Data ss:Type="String">order</Data></Cell>
|
||||
<Cell><Data ss:Type="String">magic</Data></Cell>
|
||||
<Cell><Data ss:Type="String">entry</Data></Cell>
|
||||
</Row>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">2025.01.10 09:30:00</Data></Cell>
|
||||
<Cell><Data ss:Type="String">buy</Data></Cell>
|
||||
<Cell><Data ss:Type="String">out</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0.01</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1915.00</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">15.00</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">10015.00</Data></Cell>
|
||||
<Cell><Data ss:Type="String">Layer #1</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1001</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">12345</Data></Cell>
|
||||
<Cell><Data ss:Type="String">out</Data></Cell>
|
||||
</Row>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">2025.01.15 10:45:00</Data></Cell>
|
||||
<Cell><Data ss:Type="String">sell</Data></Cell>
|
||||
<Cell><Data ss:Type="String">out</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0.01</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1920.00</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">-10.00</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">10005.00</Data></Cell>
|
||||
<Cell><Data ss:Type="String">Layer #1</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1002</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">12345</Data></Cell>
|
||||
<Cell><Data ss:Type="String">out</Data></Cell>
|
||||
</Row>
|
||||
<Row>
|
||||
<Cell><Data ss:Type="String">2025.02.05 14:00:00</Data></Cell>
|
||||
<Cell><Data ss:Type="String">sell</Data></Cell>
|
||||
<Cell><Data ss:Type="String">out</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0.01</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1945.00</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">0</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">-15.00</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">10020.00</Data></Cell>
|
||||
<Cell><Data ss:Type="String">Layer #1</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">1005</Data></Cell>
|
||||
<Cell><Data ss:Type="Number">12345</Data></Cell>
|
||||
<Cell><Data ss:Type="String">out</Data></Cell>
|
||||
</Row>
|
||||
</Table>
|
||||
</Worksheet>
|
||||
</Workbook>
|
||||
@@ -0,0 +1,673 @@
|
||||
"""Tests for analytics/analyze.py — runs without MT5 or Wine."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES = Path(__file__).parent / 'fixtures'
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from analytics.analyze import (
|
||||
PROFILES,
|
||||
load_deals, monthly_pnl, reconstruct_dd_events,
|
||||
grid_depth_histogram, depth_histogram, top_losses, loss_sequences, build_summary,
|
||||
position_pairs, cycle_stats, exit_reason_breakdown,
|
||||
direction_bias, streak_analysis, session_breakdown,
|
||||
weekday_pnl, hourly_pnl, concurrent_peak, volume_profile,
|
||||
_parse_dt, _classify_exit, _extract_depth, _classify_dd_cause,
|
||||
_lot_tier, _session_for_hour,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deals():
|
||||
return load_deals(str(FIXTURES / 'sample_deals.csv'))
|
||||
|
||||
|
||||
def test_load_deals_count(deals):
|
||||
assert len(deals) > 0
|
||||
|
||||
|
||||
def test_load_deals_numeric_fields(deals):
|
||||
for deal in deals:
|
||||
assert isinstance(deal['profit'], float)
|
||||
assert isinstance(deal['balance'], float)
|
||||
assert isinstance(deal['volume'], float)
|
||||
|
||||
|
||||
def test_monthly_pnl_groups_correctly(deals):
|
||||
result = monthly_pnl(deals)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 1
|
||||
for entry in result:
|
||||
assert 'month' in entry
|
||||
assert 'pnl' in entry
|
||||
assert 'trades' in entry
|
||||
assert 'green' in entry
|
||||
assert isinstance(entry['green'], bool)
|
||||
|
||||
|
||||
def test_monthly_pnl_only_out_entries(deals):
|
||||
"""Only 'out' entries should be counted."""
|
||||
result = monthly_pnl(deals)
|
||||
# All trades in fixture are closed, so at least one month should have trades
|
||||
total_trades = sum(m['trades'] for m in result)
|
||||
assert total_trades > 0
|
||||
|
||||
|
||||
def test_monthly_pnl_has_jan_and_feb(deals):
|
||||
result = monthly_pnl(deals)
|
||||
months = [m['month'] for m in result]
|
||||
assert '2025-01' in months
|
||||
assert '2025-02' in months
|
||||
|
||||
|
||||
def test_reconstruct_dd_events_returns_list(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
result = reconstruct_dd_events(deals, metrics)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
def test_reconstruct_dd_events_empty_on_no_deals():
|
||||
result = reconstruct_dd_events([], {})
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_grid_depth_histogram_keys(deals):
|
||||
hist = grid_depth_histogram(deals)
|
||||
assert isinstance(hist, dict)
|
||||
assert 'L1' in hist
|
||||
assert 'L2' in hist
|
||||
assert 'L3' in hist
|
||||
assert 'L8+' in hist
|
||||
|
||||
|
||||
def test_grid_depth_histogram_counts_layers(deals):
|
||||
hist = grid_depth_histogram(deals)
|
||||
# Fixture has Layer #1, #2, #3 comments
|
||||
assert hist['L1'] > 0
|
||||
assert hist['L3'] > 0
|
||||
|
||||
|
||||
def test_top_losses_are_negative(deals):
|
||||
losses = top_losses(deals)
|
||||
assert isinstance(losses, list)
|
||||
for loss in losses:
|
||||
assert loss['loss_usd'] < 0
|
||||
|
||||
|
||||
def test_top_losses_sorted_ascending(deals):
|
||||
losses = top_losses(deals)
|
||||
if len(losses) >= 2:
|
||||
assert losses[0]['loss_usd'] <= losses[1]['loss_usd']
|
||||
|
||||
|
||||
def test_loss_sequences_structure(deals):
|
||||
seqs = loss_sequences(deals)
|
||||
assert isinstance(seqs, list)
|
||||
for seq in seqs:
|
||||
assert 'length' in seq
|
||||
assert 'total_loss' in seq
|
||||
assert seq['total_loss'] < 0
|
||||
|
||||
|
||||
def test_build_summary_keys(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5, 'total_trades': 11,
|
||||
'profit_factor': 1.2, 'sharpe_ratio': 0.5, 'recovery_factor': 2.0}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
summary = build_summary(metrics, monthly, dd)
|
||||
|
||||
expected_keys = ['net_profit', 'profit_factor', 'max_dd_pct', 'sharpe_ratio',
|
||||
'total_trades', 'green_months', 'total_months',
|
||||
'worst_month', 'worst_month_pnl']
|
||||
for k in expected_keys:
|
||||
assert k in summary, f"Missing key: {k}"
|
||||
|
||||
|
||||
def test_build_summary_green_months(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
summary = build_summary(metrics, monthly, dd)
|
||||
assert summary['green_months'] >= 0
|
||||
assert summary['total_months'] >= summary['green_months']
|
||||
|
||||
|
||||
# ── Utility helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_dt_standard_format():
|
||||
dt = _parse_dt('2025.01.10 09:30:00')
|
||||
assert dt is not None
|
||||
assert dt.year == 2025
|
||||
assert dt.month == 1
|
||||
assert dt.day == 10
|
||||
assert dt.hour == 9
|
||||
|
||||
|
||||
def test_parse_dt_iso_format():
|
||||
dt = _parse_dt('2025-02-05 14:00:00')
|
||||
assert dt is not None
|
||||
assert dt.month == 2
|
||||
|
||||
|
||||
def test_parse_dt_invalid_returns_none():
|
||||
assert _parse_dt('') is None
|
||||
assert _parse_dt('not-a-date') is None
|
||||
|
||||
|
||||
def test_classify_exit_locking():
|
||||
assert _classify_exit('locking hedge', -50.0) == 'locking'
|
||||
|
||||
|
||||
def test_classify_exit_cutloss():
|
||||
assert _classify_exit('cutloss fired', -20.0) == 'cutloss'
|
||||
assert _classify_exit('cut loss', -20.0) == 'cutloss'
|
||||
|
||||
|
||||
def test_classify_exit_tp_sl_by_profit():
|
||||
assert _classify_exit('Layer #1', 15.0) == 'tp'
|
||||
assert _classify_exit('Layer #1', -10.0) == 'sl'
|
||||
|
||||
|
||||
def test_lot_tier():
|
||||
assert _lot_tier(0.01) == '0.01'
|
||||
assert _lot_tier(0.02) == '0.02-0.04'
|
||||
assert _lot_tier(0.04) == '0.02-0.04'
|
||||
assert _lot_tier(0.06) == '0.05-0.09'
|
||||
assert _lot_tier(0.10) == '0.10-0.49'
|
||||
assert _lot_tier(1.0) == '1.00+'
|
||||
|
||||
|
||||
def test_session_for_hour():
|
||||
assert _session_for_hour(3) == 'asian'
|
||||
assert _session_for_hour(9) == 'london'
|
||||
assert _session_for_hour(14) == 'london_ny_overlap'
|
||||
assert _session_for_hour(18) == 'new_york'
|
||||
assert _session_for_hour(23) == 'off_hours'
|
||||
|
||||
|
||||
# ── Position pairs ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_position_pairs_count(deals):
|
||||
pairs = position_pairs(deals)
|
||||
assert isinstance(pairs, list)
|
||||
assert len(pairs) > 0
|
||||
|
||||
|
||||
def test_position_pairs_hold_minutes(deals):
|
||||
pairs = position_pairs(deals)
|
||||
for p in pairs:
|
||||
if p['hold_minutes'] is not None:
|
||||
assert p['hold_minutes'] > 0
|
||||
|
||||
|
||||
def test_position_pairs_has_layer(deals):
|
||||
pairs = position_pairs(deals)
|
||||
layers = [p['layer'] for p in pairs if p['layer'] > 0]
|
||||
assert len(layers) > 0
|
||||
|
||||
|
||||
def test_position_pairs_profit_nonzero(deals):
|
||||
pairs = position_pairs(deals)
|
||||
for p in pairs:
|
||||
assert p['profit'] != 0.0
|
||||
|
||||
|
||||
# ── Cycle stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_cycle_stats_structure(deals):
|
||||
result = cycle_stats(deals)
|
||||
assert 'total_cycles' in result
|
||||
assert 'win_rate' in result
|
||||
assert 'avg_profit' in result
|
||||
assert 'win_rate_by_depth' in result
|
||||
|
||||
|
||||
def test_cycle_stats_total_cycles(deals):
|
||||
result = cycle_stats(deals)
|
||||
assert result['total_cycles'] > 0
|
||||
|
||||
|
||||
def test_cycle_stats_win_rate_range(deals):
|
||||
result = cycle_stats(deals)
|
||||
assert 0.0 <= result['win_rate'] <= 100.0
|
||||
|
||||
|
||||
def test_cycle_stats_empty():
|
||||
result = cycle_stats([])
|
||||
assert result['total_cycles'] == 0
|
||||
|
||||
|
||||
# ── Exit reason breakdown ──────────────────────────────────────────────────────
|
||||
|
||||
def test_exit_reason_breakdown_structure(deals):
|
||||
result = exit_reason_breakdown(deals)
|
||||
assert isinstance(result, dict)
|
||||
for reason, data in result.items():
|
||||
assert 'count' in data
|
||||
assert 'total_pnl' in data
|
||||
assert 'avg_pnl' in data
|
||||
assert data['count'] > 0
|
||||
|
||||
|
||||
def test_exit_reason_breakdown_has_cutloss(deals):
|
||||
result = exit_reason_breakdown(deals)
|
||||
# fixture has 'cutloss' in comment for some deals
|
||||
assert 'cutloss' in result
|
||||
|
||||
|
||||
def test_exit_reason_breakdown_counts_match(deals):
|
||||
result = exit_reason_breakdown(deals)
|
||||
total_counted = sum(r['count'] for r in result.values())
|
||||
closed_with_pnl = [d for d in deals
|
||||
if 'out' in d.get('entry', '').lower() and d.get('profit', 0.0) != 0.0]
|
||||
assert total_counted == len(closed_with_pnl)
|
||||
|
||||
|
||||
# ── Direction bias ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_direction_bias_keys(deals):
|
||||
result = direction_bias(deals)
|
||||
assert isinstance(result, dict)
|
||||
# fixture has both buy and sell
|
||||
assert 'buy' in result
|
||||
assert 'sell' in result
|
||||
|
||||
|
||||
def test_direction_bias_win_rate_range(deals):
|
||||
result = direction_bias(deals)
|
||||
for d, s in result.items():
|
||||
assert 0.0 <= s['win_rate'] <= 100.0
|
||||
assert s['trades'] > 0
|
||||
|
||||
|
||||
def test_direction_bias_buy_profitable(deals):
|
||||
result = direction_bias(deals)
|
||||
# fixture: buy deals net positive
|
||||
assert result['buy']['total_pnl'] > 0
|
||||
|
||||
|
||||
# ── Streak analysis ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_streak_analysis_structure(deals):
|
||||
result = streak_analysis(deals)
|
||||
assert isinstance(result, dict)
|
||||
for key in ('max_win_streak', 'max_loss_streak', 'current_streak', 'current_streak_type'):
|
||||
assert key in result
|
||||
|
||||
|
||||
def test_streak_analysis_nonnegative(deals):
|
||||
result = streak_analysis(deals)
|
||||
assert result['max_win_streak'] >= 0
|
||||
assert result['max_loss_streak'] >= 0
|
||||
assert result['current_streak'] >= 1
|
||||
|
||||
|
||||
def test_streak_analysis_type_valid(deals):
|
||||
result = streak_analysis(deals)
|
||||
assert result['current_streak_type'] in ('win', 'loss')
|
||||
|
||||
|
||||
def test_streak_analysis_empty():
|
||||
assert streak_analysis([]) == {}
|
||||
|
||||
|
||||
# ── Session breakdown ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_session_breakdown_structure(deals):
|
||||
result = session_breakdown(deals)
|
||||
assert isinstance(result, dict)
|
||||
for session, data in result.items():
|
||||
assert 'trades' in data
|
||||
assert 'win_rate' in data
|
||||
assert 'total_pnl' in data
|
||||
|
||||
|
||||
def test_session_breakdown_has_sessions(deals):
|
||||
result = session_breakdown(deals)
|
||||
# fixture has deals at 09:00, 10:00, 14:00, 15:00, 16:00 (London + London/NY)
|
||||
# and 02:30, 03:15 (Asian), 20:00-21:30 (NY)
|
||||
known_sessions = {'london', 'london_ny_overlap', 'asian', 'new_york'}
|
||||
assert len(set(result.keys()) & known_sessions) >= 2
|
||||
|
||||
|
||||
def test_session_breakdown_win_rate_range(deals):
|
||||
result = session_breakdown(deals)
|
||||
for session, data in result.items():
|
||||
assert 0.0 <= data['win_rate'] <= 100.0
|
||||
|
||||
|
||||
# ── Weekday P/L ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_weekday_pnl_structure(deals):
|
||||
result = weekday_pnl(deals)
|
||||
assert isinstance(result, list)
|
||||
for entry in result:
|
||||
assert 'day' in entry
|
||||
assert 'pnl' in entry
|
||||
assert 'trades' in entry
|
||||
assert 'win_rate' in entry
|
||||
|
||||
|
||||
def test_weekday_pnl_day_names(deals):
|
||||
result = weekday_pnl(deals)
|
||||
valid_days = {'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'}
|
||||
for entry in result:
|
||||
assert entry['day'] in valid_days
|
||||
|
||||
|
||||
def test_weekday_pnl_has_results(deals):
|
||||
result = weekday_pnl(deals)
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
# ── Hourly P/L ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_hourly_pnl_structure(deals):
|
||||
result = hourly_pnl(deals)
|
||||
assert isinstance(result, list)
|
||||
for entry in result:
|
||||
assert 'hour' in entry
|
||||
assert 0 <= entry['hour'] <= 23
|
||||
assert 'pnl' in entry
|
||||
assert 'trades' in entry
|
||||
|
||||
|
||||
def test_hourly_pnl_has_results(deals):
|
||||
result = hourly_pnl(deals)
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
# ── Concurrent peak ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_concurrent_peak_structure(deals):
|
||||
result = concurrent_peak(deals)
|
||||
assert 'peak_open' in result
|
||||
assert 'peak_time' in result
|
||||
|
||||
|
||||
def test_concurrent_peak_at_least_one(deals):
|
||||
result = concurrent_peak(deals)
|
||||
assert result['peak_open'] >= 1
|
||||
|
||||
|
||||
def test_concurrent_peak_multi_layer(deals):
|
||||
# fixture has a cycle where L2 and L3 open before close → peak >= 2
|
||||
result = concurrent_peak(deals)
|
||||
assert result['peak_open'] >= 2
|
||||
|
||||
|
||||
# ── Volume profile ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_volume_profile_structure(deals):
|
||||
result = volume_profile(deals)
|
||||
assert isinstance(result, list)
|
||||
for entry in result:
|
||||
assert 'lot_tier' in entry
|
||||
assert 'pnl' in entry
|
||||
assert 'trades' in entry
|
||||
assert 'win_rate' in entry
|
||||
|
||||
|
||||
def test_volume_profile_has_micro_lots(deals):
|
||||
result = volume_profile(deals)
|
||||
tiers = [e['lot_tier'] for e in result]
|
||||
assert '0.01' in tiers
|
||||
|
||||
|
||||
def test_volume_profile_win_rate_range(deals):
|
||||
result = volume_profile(deals)
|
||||
for entry in result:
|
||||
assert 0.0 <= entry['win_rate'] <= 100.0
|
||||
|
||||
|
||||
# ── build_summary with new stats ───────────────────────────────────────────────
|
||||
|
||||
def test_build_summary_with_streak(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
streak = streak_analysis(deals)
|
||||
summary = build_summary(metrics, monthly, dd, streak=streak)
|
||||
assert 'max_win_streak' in summary
|
||||
assert 'max_loss_streak' in summary
|
||||
assert 'current_streak_type' in summary
|
||||
|
||||
|
||||
def test_build_summary_with_bias(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
bias = direction_bias(deals)
|
||||
summary = build_summary(metrics, monthly, dd, bias=bias)
|
||||
assert 'buy_win_rate' in summary or 'sell_win_rate' in summary
|
||||
|
||||
|
||||
def test_build_summary_with_cycles(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
cycles = cycle_stats(deals)
|
||||
summary = build_summary(metrics, monthly, dd, cycles=cycles)
|
||||
assert 'cycle_win_rate' in summary
|
||||
assert 'total_cycles' in summary
|
||||
|
||||
|
||||
# ── Strategy profiles ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_profiles_registry():
|
||||
"""All expected strategy names are registered."""
|
||||
for name in ('generic', 'grid', 'scalper', 'trend', 'hedge'):
|
||||
assert name in PROFILES
|
||||
p = PROFILES[name]
|
||||
assert 'name' in p
|
||||
assert 'exit_keywords' in p
|
||||
assert 'dd_cause_keywords' in p
|
||||
assert 'cycle_group_by' in p
|
||||
assert 'cycle_gap_min' in p
|
||||
|
||||
|
||||
def test_profiles_depth_re():
|
||||
assert PROFILES['grid']['depth_re'] is not None
|
||||
assert PROFILES['generic']['depth_re'] is None
|
||||
assert PROFILES['scalper']['depth_re'] is None
|
||||
assert PROFILES['trend']['depth_re'] is None
|
||||
assert PROFILES['hedge']['depth_re'] is None
|
||||
|
||||
|
||||
# ── _extract_depth ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_depth_grid_pattern():
|
||||
depth_re = PROFILES['grid']['depth_re']
|
||||
assert _extract_depth('Layer #3', depth_re) == 3
|
||||
assert _extract_depth('Layer #1', depth_re) == 1
|
||||
assert _extract_depth('layer 7', depth_re) == 7
|
||||
|
||||
|
||||
def test_extract_depth_no_pattern():
|
||||
assert _extract_depth('Layer #3', None) == 0
|
||||
assert _extract_depth('', None) == 0
|
||||
|
||||
|
||||
def test_extract_depth_no_match():
|
||||
assert _extract_depth('TP hit', PROFILES['grid']['depth_re']) == 0
|
||||
|
||||
|
||||
# ── _classify_exit with profiles ──────────────────────────────────────────────
|
||||
|
||||
def test_classify_exit_grid_locking():
|
||||
assert _classify_exit('locking hedge', -50.0, PROFILES['grid']) == 'locking'
|
||||
|
||||
|
||||
def test_classify_exit_grid_cutloss():
|
||||
assert _classify_exit('cutloss fired', -20.0, PROFILES['grid']) == 'cutloss'
|
||||
|
||||
|
||||
def test_classify_exit_scalper_manual():
|
||||
assert _classify_exit('manual close', -5.0, PROFILES['scalper']) == 'manual'
|
||||
|
||||
|
||||
def test_classify_exit_scalper_trailing():
|
||||
assert _classify_exit('trailing stop', 10.0, PROFILES['scalper']) == 'trailing'
|
||||
|
||||
|
||||
def test_classify_exit_trend_breakeven():
|
||||
assert _classify_exit('breakeven stop', 0.5, PROFILES['trend']) == 'breakeven'
|
||||
|
||||
|
||||
def test_classify_exit_trend_partial():
|
||||
assert _classify_exit('partial scale out', 15.0, PROFILES['trend']) == 'partial'
|
||||
|
||||
|
||||
def test_classify_exit_hedge_net_close():
|
||||
assert _classify_exit('net close', -30.0, PROFILES['hedge']) == 'net_close'
|
||||
|
||||
|
||||
def test_classify_exit_generic_fallback():
|
||||
"""Generic profile has no keywords — falls back to profit sign."""
|
||||
assert _classify_exit('Layer #3 locking', -50.0, PROFILES['generic']) == 'sl'
|
||||
assert _classify_exit('Layer #1', 15.0, PROFILES['generic']) == 'tp'
|
||||
|
||||
|
||||
# ── _classify_dd_cause ────────────────────────────────────────────────────────
|
||||
|
||||
def test_classify_dd_cause_grid():
|
||||
assert _classify_dd_cause('locking total', PROFILES['grid']) == 'locking_cascade'
|
||||
assert _classify_dd_cause('cutloss fired', PROFILES['grid']) == 'cutloss'
|
||||
assert _classify_dd_cause('zombie exit', PROFILES['grid']) == 'zombie_exit'
|
||||
|
||||
|
||||
def test_classify_dd_cause_generic_unknown():
|
||||
assert _classify_dd_cause('locking total', PROFILES['generic']) == 'unknown'
|
||||
assert _classify_dd_cause('', PROFILES['generic']) == 'unknown'
|
||||
|
||||
|
||||
def test_classify_dd_cause_scalper_stop():
|
||||
assert _classify_dd_cause('sl hit', PROFILES['scalper']) == 'stop_loss'
|
||||
|
||||
|
||||
def test_classify_dd_cause_trend_whipsaw():
|
||||
assert _classify_dd_cause('stop loss', PROFILES['trend']) == 'whipsaw'
|
||||
|
||||
|
||||
# ── depth_histogram with profiles ─────────────────────────────────────────────
|
||||
|
||||
def test_depth_histogram_grid_returns_layers(deals):
|
||||
result = depth_histogram(deals, PROFILES['grid'])
|
||||
assert isinstance(result, dict)
|
||||
assert 'L1' in result
|
||||
assert result['L1'] > 0
|
||||
|
||||
|
||||
def test_depth_histogram_generic_returns_empty(deals):
|
||||
"""Generic profile has no depth_re → empty dict."""
|
||||
result = depth_histogram(deals, PROFILES['generic'])
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_depth_histogram_scalper_returns_empty(deals):
|
||||
result = depth_histogram(deals, PROFILES['scalper'])
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_grid_depth_histogram_is_alias(deals):
|
||||
"""grid_depth_histogram must equal depth_histogram with grid profile."""
|
||||
assert grid_depth_histogram(deals) == depth_histogram(deals, PROFILES['grid'])
|
||||
|
||||
|
||||
# ── cycle_stats with profiles ─────────────────────────────────────────────────
|
||||
|
||||
def test_cycle_stats_grid_profile(deals):
|
||||
result = cycle_stats(deals, PROFILES['grid'])
|
||||
assert result['total_cycles'] > 0
|
||||
assert 0.0 <= result['win_rate'] <= 100.0
|
||||
|
||||
|
||||
def test_cycle_stats_scalper_profile(deals):
|
||||
"""Scalper uses magic-only grouping and 10-min gap."""
|
||||
result = cycle_stats(deals, PROFILES['scalper'])
|
||||
assert 'total_cycles' in result
|
||||
assert result['total_cycles'] > 0
|
||||
|
||||
|
||||
def test_cycle_stats_generic_profile(deals):
|
||||
result = cycle_stats(deals, PROFILES['generic'])
|
||||
assert 'total_cycles' in result
|
||||
|
||||
|
||||
def test_cycle_stats_scalper_vs_grid_differ(deals):
|
||||
"""Different grouping rules can produce different cycle counts."""
|
||||
grid_result = cycle_stats(deals, PROFILES['grid'])
|
||||
scalper_result = cycle_stats(deals, PROFILES['scalper'])
|
||||
# Both must be valid; counts may differ due to grouping
|
||||
assert grid_result['total_cycles'] >= 0
|
||||
assert scalper_result['total_cycles'] >= 0
|
||||
|
||||
|
||||
# ── exit_reason_breakdown with profiles ───────────────────────────────────────
|
||||
|
||||
def test_exit_reason_breakdown_grid(deals):
|
||||
result = exit_reason_breakdown(deals, PROFILES['grid'])
|
||||
assert 'cutloss' in result # fixture has "cutloss" in comments
|
||||
|
||||
|
||||
def test_exit_reason_breakdown_generic_only_tp_sl(deals):
|
||||
"""Generic profile has no keywords → only 'tp' and 'sl' keys."""
|
||||
result = exit_reason_breakdown(deals, PROFILES['generic'])
|
||||
for reason in result:
|
||||
assert reason in ('tp', 'sl'), f"Unexpected reason '{reason}' from generic profile"
|
||||
|
||||
|
||||
def test_exit_reason_breakdown_scalper_keywords(deals):
|
||||
"""Scalper profile recognises 'cutloss' comment as 'manual' (not 'cutloss')."""
|
||||
result = exit_reason_breakdown(deals, PROFILES['scalper'])
|
||||
# 'cutloss' is not a scalper keyword → falls back to profit-sign → 'sl'
|
||||
assert 'cutloss' not in result
|
||||
|
||||
|
||||
def test_exit_reason_breakdown_counts_sum(deals):
|
||||
"""Total count must equal number of non-zero closed deals, regardless of profile."""
|
||||
closed = [d for d in deals
|
||||
if 'out' in d.get('entry', '').lower() and d.get('profit', 0.0) != 0.0]
|
||||
for profile in PROFILES.values():
|
||||
result = exit_reason_breakdown(deals, profile)
|
||||
assert sum(r['count'] for r in result.values()) == len(closed)
|
||||
|
||||
|
||||
# ── reconstruct_dd_events with profiles ───────────────────────────────────────
|
||||
|
||||
def test_dd_events_cause_generic_unknown(deals):
|
||||
"""Generic profile → all causes must be 'unknown'."""
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
events = reconstruct_dd_events(deals, metrics, PROFILES['generic'])
|
||||
for ev in events:
|
||||
assert ev['cause'] == 'unknown'
|
||||
|
||||
|
||||
def test_dd_events_cause_grid_classified(deals):
|
||||
"""Grid profile → cause is classified from comment keywords."""
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 5.0}
|
||||
events = reconstruct_dd_events(deals, metrics, PROFILES['grid'])
|
||||
valid = {'locking_cascade', 'cutloss', 'zombie_exit', 'spike_entry', 'unknown'}
|
||||
for ev in events:
|
||||
assert ev['cause'] in valid
|
||||
|
||||
|
||||
# ── build_summary strategy field ──────────────────────────────────────────────
|
||||
|
||||
def test_build_summary_strategy_field(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
summary = build_summary(metrics, monthly, dd, strategy='scalper')
|
||||
assert summary['strategy'] == 'scalper'
|
||||
|
||||
|
||||
def test_build_summary_no_strategy_field(deals):
|
||||
metrics = {'net_profit': 38.0, 'max_dd_pct': 1.5}
|
||||
monthly = monthly_pnl(deals)
|
||||
dd = reconstruct_dd_events(deals, metrics)
|
||||
summary = build_summary(metrics, monthly, dd)
|
||||
assert 'strategy' not in summary
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user