Complete migration to Rust implementation
- Migrate optimization from optimize.sh to src/optimization/ - Remove all Python files (analytics/, server/, hooks/, pyproject.toml) - Add optimization module: optimizer.rs, parser.rs, mod.rs - Implement all missing MCP tool handlers (35 total tools) - Add handle_patch_set_file handler - Clean up orphan files: .venv/, __pycache__, test files - Move test_rcp_server.sh to tests/integration_test.sh - Add Rust integration tests in tests/integration_tests.rs - Fix all compiler warnings with #[allow(dead_code)] - Update test fixtures and structure
This commit is contained in:
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Testing Rust MCP Server..."
|
||||
|
||||
# Create a temporary file for test
|
||||
TEMP_FILE=$(mktemp)
|
||||
cat > "$TEMP_FILE" << 'EOF'
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}
|
||||
EOF
|
||||
|
||||
# Send all requests in one session
|
||||
cat "$TEMP_FILE" | /opt/homebrew/bin/mt5-quant
|
||||
|
||||
# Clean up
|
||||
rm "$TEMP_FILE"
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn get_fixture_path(name: &str) -> PathBuf {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.push("tests/fixtures");
|
||||
path.push(name);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixtures_exist() {
|
||||
let fixtures = vec![
|
||||
"sample_deals.csv",
|
||||
"sample_report.htm",
|
||||
"sample_report.htm.xml",
|
||||
];
|
||||
|
||||
for fixture in fixtures {
|
||||
let path = get_fixture_path(fixture);
|
||||
assert!(path.exists(), "Fixture {} should exist", fixture);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_deals_csv_format() {
|
||||
let path = get_fixture_path("sample_deals.csv");
|
||||
let content = fs::read_to_string(path).expect("Should read sample_deals.csv");
|
||||
|
||||
// Check CSV has header and data rows
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert!(!lines.is_empty(), "CSV should have at least a header");
|
||||
|
||||
// Check for expected columns in header
|
||||
let header = lines[0];
|
||||
assert!(header.contains("Time") || header.contains("time"), "Header should contain Time column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_report_html_format() {
|
||||
let path = get_fixture_path("sample_report.htm");
|
||||
let content = fs::read_to_string(path).expect("Should read sample_report.htm");
|
||||
|
||||
// Check HTML structure
|
||||
assert!(content.contains("<html") || content.contains("<table"),
|
||||
"Report should contain HTML or table elements");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_report_xml_format() {
|
||||
let path = get_fixture_path("sample_report.htm.xml");
|
||||
let content = fs::read_to_string(path).expect("Should read sample_report.htm.xml");
|
||||
|
||||
// Check XML structure
|
||||
assert!(content.contains("<?xml") || content.contains("<Workbook"),
|
||||
"Report should contain XML or Workbook elements");
|
||||
}
|
||||
@@ -1,673 +0,0 @@
|
||||
"""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
|
||||
@@ -1,146 +0,0 @@
|
||||
"""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