3f763827f4
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
674 lines
23 KiB
Python
674 lines
23 KiB
Python
"""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
|