#!/usr/bin/env python3 """ analyze.py — Deal-level backtest analysis engine. Strategy-agnostic core + strategy-specific profiles. Usage: # Generic (works for any EA — no hardcoded keyword assumptions) python3 analytics/analyze.py generic deals.csv --output-dir DIR # Strategy-specific presets python3 analytics/analyze.py grid deals.csv --output-dir DIR python3 analytics/analyze.py scalper deals.csv --output-dir DIR python3 analytics/analyze.py trend deals.csv --output-dir DIR python3 analytics/analyze.py hedge deals.csv --output-dir DIR # Legacy (no subcommand) — defaults to 'grid' for backward compatibility python3 analytics/analyze.py deals.csv --output-dir DIR # Flags --deep Add hourly_pnl and volume_profile --stdout Print JSON to stdout instead of writing analysis.json Entry points (after pip install -e .): mt5-analyze generic analysis mt5-analyze-grid grid / martingale mt5-analyze-scalper scalper mt5-analyze-trend trend following mt5-analyze-hedge hedging """ import argparse import csv import json import os import re import sys from collections import defaultdict from datetime import datetime from typing import Optional # ── Strategy profiles ───────────────────────────────────────────────────────── # # Each profile is a plain dict with: # name – human-readable label # depth_re – regex to extract depth number from comment (None = no depth tracking) # exit_keywords – {reason: [keywords]} for comment-based exit classification # dd_cause_keywords– {cause: [keywords]} for comment-based DD cause classification # cycle_group_by – 'magic' | 'magic+direction' | None # cycle_gap_min – minutes between opens that marks a new cycle boundary # # When exit_keywords is empty, classification falls back to profit-sign (tp/sl). # When dd_cause_keywords is empty, dd_events cause = 'unknown'. PROFILES: dict[str, dict] = { 'generic': { 'name': 'Generic', 'depth_re': None, 'exit_keywords': {}, 'dd_cause_keywords': {}, 'cycle_group_by': 'magic', 'cycle_gap_min': 60, }, 'grid': { 'name': 'Grid / Martingale', 'depth_re': r'[Ll]ayer\s*#?(\d+)', 'exit_keywords': { 'locking': ['locking', 'lock'], 'cutloss': ['cutloss', 'cut loss', 'cut_loss'], 'zombie': ['zombie'], 'timeout': ['timeout', 'time out', 'time_out'], }, 'dd_cause_keywords': { 'locking_cascade': ['locking', 'lock'], 'cutloss': ['cutloss', 'cut'], 'zombie_exit': ['zombie'], 'spike_entry': ['spike'], }, 'cycle_group_by': 'magic+direction', 'cycle_gap_min': 60, }, 'scalper': { 'name': 'Scalper', 'depth_re': None, 'exit_keywords': { 'tp': ['tp', 'take profit', 'target'], 'sl': ['sl', 'stop loss', 'stoploss'], 'manual': ['manual', 'close'], 'trailing': ['trailing', 'trail'], }, 'dd_cause_keywords': { 'stop_loss': ['sl', 'stop'], 'manual_close': ['manual', 'close'], }, 'cycle_group_by': 'magic', 'cycle_gap_min': 10, }, 'trend': { 'name': 'Trend Following', 'depth_re': None, 'exit_keywords': { # More specific patterns must come before general ones to avoid substring # false-positives (e.g. 'stop' inside 'breakeven stop' / 'trailing stop'). 'breakeven': ['breakeven', 'break even', 'be stop'], 'trailing': ['trailing', 'trail'], 'partial': ['partial', 'scale out'], 'tp': ['tp', 'target', 'take profit'], 'sl': ['sl', 'stop loss', 'stoploss'], }, 'dd_cause_keywords': { 'whipsaw': ['stop loss', 'stoploss'], 'trailing_stop': ['trailing'], 'breakeven_stop': ['breakeven', 'be stop'], }, 'cycle_group_by': 'magic', 'cycle_gap_min': 240, }, 'hedge': { 'name': 'Hedging', 'depth_re': None, 'exit_keywords': { 'tp': ['tp'], 'sl': ['sl'], 'net_close': ['net', 'hedge close', 'hedge_close'], 'partial': ['partial', 'reduce'], }, 'dd_cause_keywords': { 'hedge_unwind': ['net', 'hedge'], 'correlation_break': ['sl', 'stop'], }, 'cycle_group_by': 'magic+direction', 'cycle_gap_min': 120, }, } # ── Utility ─────────────────────────────────────────────────────────────────── def _parse_dt(time_str: str) -> Optional[datetime]: """Parse MT5 time string ('2025.01.10 09:30:00') to datetime.""" if not time_str: return None s = time_str.strip() for length, fmt in [(19, '%Y.%m.%d %H:%M:%S'), (19, '%Y-%m-%d %H:%M:%S'), (10, '%Y.%m.%d'), (10, '%Y-%m-%d')]: try: return datetime.strptime(s[:length], fmt) except (ValueError, IndexError): continue return None def _extract_depth(comment: str, depth_re: Optional[str]) -> int: """Extract a numeric depth from comment using the given regex. Returns 0 if not matched.""" if not depth_re or not comment: return 0 match = re.search(depth_re, comment) return int(match.group(1)) if match else 0 def _layer_from_comment(comment: str) -> int: """Grid-specific: extract Layer #N number. Kept for backward compatibility.""" return _extract_depth(comment, PROFILES['grid']['depth_re']) def _classify_exit(comment: str, profit: float, profile: Optional[dict] = None) -> str: """ Classify exit reason from deal comment. If profile is provided, uses its exit_keywords dict. If profile is None, falls back to the grid profile keywords (backward compat). If no keywords match, returns 'tp' (profit > 0) or 'sl' (profit <= 0). """ kw_map = (profile or PROFILES['grid'])['exit_keywords'] c = comment.lower() for reason, keywords in kw_map.items(): if any(kw in c for kw in keywords): return reason return 'tp' if profit > 0 else 'sl' def _classify_dd_cause(comment: str, profile: dict) -> str: """Classify DD event cause from comment using profile's dd_cause_keywords.""" c = comment.lower() for cause, keywords in profile.get('dd_cause_keywords', {}).items(): if any(kw in c for kw in keywords): return cause return 'unknown' def _lot_tier(vol: float) -> str: if vol <= 0.01: return '0.01' if vol <= 0.04: return '0.02-0.04' if vol <= 0.09: return '0.05-0.09' if vol <= 0.49: return '0.10-0.49' if vol <= 0.99: return '0.50-0.99' return '1.00+' def _session_for_hour(hour: int) -> str: if 13 <= hour < 17: return 'london_ny_overlap' if 8 <= hour < 17: return 'london' if 17 <= hour < 22: return 'new_york' if 0 <= hour < 8: return 'asian' return 'off_hours' # ── Loaders ─────────────────────────────────────────────────────────────────── def load_deals(csv_path: str) -> list[dict]: deals = [] with open(csv_path, newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: row = {k: (v or '').strip() for k, v in row.items()} for field in ('profit', 'volume', 'price', 'balance'): try: row[field] = float(row.get(field, 0).replace(',', '') or 0) except ValueError: row[field] = 0.0 deals.append(row) return deals def load_metrics(metrics_path: str) -> dict: if not os.path.exists(metrics_path): return {} with open(metrics_path) as f: return json.load(f) # ── Generic analytics (strategy-agnostic) ──────────────────────────────────── def monthly_pnl(deals: list[dict]) -> list[dict]: monthly: dict[str, dict] = defaultdict(lambda: {'pnl': 0.0, 'trades': 0}) for deal in deals: time_str = deal.get('time', '') profit = deal.get('profit', 0.0) entry = deal.get('entry', '').lower() if 'out' not in entry and entry != '': continue if not time_str or profit == 0.0: continue try: dt = datetime.strptime(time_str[:10].replace('.', '-'), '%Y-%m-%d') month = dt.strftime('%Y-%m') except (ValueError, IndexError): continue monthly[month]['pnl'] += profit monthly[month]['trades'] += 1 return [ {'month': m, 'pnl': round(d['pnl'], 2), 'trades': d['trades'], 'green': d['pnl'] >= 0} for m in sorted(monthly) for d in [monthly[m]] ] def reconstruct_dd_events(deals: list[dict], metrics: dict, profile: Optional[dict] = None) -> list[dict]: """Walk deals chronologically, reconstruct drawdown events. Cause classified by profile.""" if not deals: return [] _profile = profile or PROFILES['grid'] balance_curve = [] peak_balance = 0.0 initial_balance = None for deal in deals: balance = deal.get('balance', 0.0) if balance > 0: if initial_balance is None: initial_balance = balance peak_balance = max(peak_balance, balance) dd_pct = (peak_balance - balance) / peak_balance * 100 if peak_balance > 0 else 0 balance_curve.append({ 'time': deal.get('time', ''), 'balance': balance, 'dd_pct': round(dd_pct, 3), 'profit': deal.get('profit', 0.0), 'comment': deal.get('comment', ''), }) if not balance_curve: return [] events, in_dd, dd_start_idx = [], False, None threshold = 1.0 for i, point in enumerate(balance_curve): if not in_dd and point['dd_pct'] > threshold: in_dd, dd_start_idx = True, i elif in_dd and point['dd_pct'] < threshold: peak_idx = max(range(dd_start_idx, i + 1), key=lambda x: balance_curve[x]['dd_pct']) event = _build_dd_event(balance_curve, dd_start_idx, peak_idx, i, _profile) if event['peak_dd_pct'] > 1.0: events.append(event) in_dd, dd_start_idx = False, None if in_dd and dd_start_idx is not None: peak_idx = max(range(dd_start_idx, len(balance_curve)), key=lambda x: balance_curve[x]['dd_pct']) event = _build_dd_event(balance_curve, dd_start_idx, peak_idx, None, _profile) if event['peak_dd_pct'] > 1.0: events.append(event) events.sort(key=lambda e: e['peak_dd_pct'], reverse=True) return events[:10] def _build_dd_event(curve: list[dict], start_idx: int, peak_idx: int, recovery_idx: Optional[int], profile: dict) -> dict: start = curve[start_idx] peak = curve[peak_idx] event: dict = { 'peak_dd_pct': round(peak['dd_pct'], 2), 'start_date': start['time'][:10].replace('.', '-') if start['time'] else '', 'end_date': peak['time'][:10].replace('.', '-') if peak['time'] else '', 'cause': _classify_dd_cause(peak.get('comment', ''), profile), } if recovery_idx is not None: rec = curve[recovery_idx] event['recovery_date'] = rec['time'][:10].replace('.', '-') if rec['time'] else None try: s_dt = datetime.strptime(event['start_date'], '%Y-%m-%d') r_dt = datetime.strptime(event['recovery_date'], '%Y-%m-%d') event['recovery_days'] = (r_dt - s_dt).days except (ValueError, TypeError): event['recovery_days'] = None else: event['recovery_date'] = None event['recovery_days'] = None try: s_dt = datetime.strptime(event['start_date'], '%Y-%m-%d') e_dt = datetime.strptime(event['end_date'], '%Y-%m-%d') event['duration_days'] = (e_dt - s_dt).days except ValueError: event['duration_days'] = 0 return event def top_losses(deals: list[dict], n: int = 10) -> list[dict]: losses = [ { 'date': deal.get('time', '')[:10].replace('.', '-'), 'loss_usd': round(deal['profit'], 2), 'comment': deal.get('comment', ''), 'grid_depth_at_close': _layer_from_comment(deal.get('comment', '')), 'volume': deal.get('volume', 0.0), } for deal in deals if deal.get('profit', 0.0) < 0 ] losses.sort(key=lambda x: x['loss_usd']) return losses[:n] def loss_sequences(deals: list[dict]) -> list[dict]: closed = [d for d in deals if 'out' in d.get('entry', '').lower() and d.get('profit', 0) != 0] if not closed: return [] sequences, current_seq = [], [] for deal in closed: if deal['profit'] < 0: current_seq.append(deal) else: if len(current_seq) >= 2: total = sum(d['profit'] for d in current_seq) sequences.append({ 'length': len(current_seq), 'total_loss': round(total, 2), 'start': current_seq[0].get('time', '')[:10].replace('.', '-'), 'end': current_seq[-1].get('time', '')[:10].replace('.', '-'), }) current_seq = [] if len(current_seq) >= 2: total = sum(d['profit'] for d in current_seq) sequences.append({ 'length': len(current_seq), 'total_loss': round(total, 2), 'start': current_seq[0].get('time', '')[:10].replace('.', '-'), 'end': current_seq[-1].get('time', '')[:10].replace('.', '-'), }) sequences.sort(key=lambda x: x['total_loss']) return sequences[:5] def position_pairs(deals: list[dict]) -> list[dict]: """Match in/out deals by order ticket → hold time + depth at close.""" open_pos: dict[str, dict] = {} pairs = [] for deal in deals: order = deal.get('order', '') entry = deal.get('entry', '').lower() if 'in' in entry and 'out' not in entry: open_pos[order] = deal elif 'out' in entry: profit = deal.get('profit', 0.0) if profit == 0.0: continue in_deal = open_pos.pop(order, None) dt_out = _parse_dt(deal.get('time', '')) comment = deal.get('comment', '') hold_minutes = None if in_deal and dt_out: dt_in = _parse_dt(in_deal.get('time', '')) if dt_in: hold_minutes = round((dt_out - dt_in).total_seconds() / 60, 1) pairs.append({ 'time': deal.get('time', ''), 'type': deal.get('type', ''), 'profit': profit, 'volume': deal.get('volume', 0.0), 'layer': _layer_from_comment(comment), 'hold_minutes': hold_minutes, 'comment': comment, 'magic': deal.get('magic', ''), 'order': order, }) return pairs def direction_bias(deals: list[dict]) -> dict: stats: dict[str, dict] = { 'buy': {'trades': 0, 'wins': 0, 'total_pnl': 0.0}, 'sell': {'trades': 0, 'wins': 0, 'total_pnl': 0.0}, } for deal in deals: if 'out' not in deal.get('entry', '').lower(): continue profit = deal.get('profit', 0.0) if profit == 0.0: continue d = deal.get('type', '').lower() if d not in stats: continue stats[d]['trades'] += 1 stats[d]['total_pnl'] += profit if profit > 0: stats[d]['wins'] += 1 return { d: { 'trades': s['trades'], 'win_rate': round(s['wins'] / s['trades'] * 100, 1), 'total_pnl': round(s['total_pnl'], 2), 'avg_pnl': round(s['total_pnl'] / s['trades'], 2), } for d, s in stats.items() if s['trades'] > 0 } def streak_analysis(deals: list[dict]) -> dict: closed = [d for d in deals if 'out' in d.get('entry', '').lower() and d.get('profit', 0.0) != 0.0] if not closed: return {} max_win_streak = max_loss_streak = cur_win = cur_loss = 0 max_win_start = max_win_end = max_loss_start = max_loss_end = '' win_run_start = loss_run_start = '' for deal in closed: profit = deal['profit'] t = deal.get('time', '')[:10].replace('.', '-') if profit > 0: if cur_win == 0: win_run_start = t cur_win += 1 cur_loss = 0 if cur_win > max_win_streak: max_win_streak = cur_win max_win_start = win_run_start max_win_end = t else: if cur_loss == 0: loss_run_start = t cur_loss += 1 cur_win = 0 if cur_loss > max_loss_streak: max_loss_streak = cur_loss max_loss_start = loss_run_start max_loss_end = t last = closed[-1] return { 'max_win_streak': max_win_streak, 'max_win_start': max_win_start, 'max_win_end': max_win_end, 'max_loss_streak': max_loss_streak, 'max_loss_start': max_loss_start, 'max_loss_end': max_loss_end, 'current_streak': cur_win if last['profit'] > 0 else cur_loss, 'current_streak_type': 'win' if last['profit'] > 0 else 'loss', } def session_breakdown(deals: list[dict]) -> dict: """P/L by trading session (UTC hour-based).""" sessions: dict = defaultdict(lambda: {'trades': 0, 'wins': 0, 'total_pnl': 0.0}) for deal in deals: if 'out' not in deal.get('entry', '').lower(): continue profit = deal.get('profit', 0.0) if profit == 0.0: continue dt = _parse_dt(deal.get('time', '')) if not dt: continue s = _session_for_hour(dt.hour) sessions[s]['trades'] += 1 sessions[s]['total_pnl'] += profit if profit > 0: sessions[s]['wins'] += 1 return { s: { 'trades': d['trades'], 'win_rate': round(d['wins'] / d['trades'] * 100, 1) if d['trades'] > 0 else 0.0, 'total_pnl': round(d['total_pnl'], 2), } for s, d in sessions.items() } def weekday_pnl(deals: list[dict]) -> list[dict]: DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] by_day: dict = defaultdict(lambda: {'pnl': 0.0, 'trades': 0, 'wins': 0}) for deal in deals: if 'out' not in deal.get('entry', '').lower(): continue profit = deal.get('profit', 0.0) if profit == 0.0: continue dt = _parse_dt(deal.get('time', '')) if not dt: continue by_day[dt.weekday()]['pnl'] += profit by_day[dt.weekday()]['trades'] += 1 if profit > 0: by_day[dt.weekday()]['wins'] += 1 return [ { 'day': DAY_NAMES[day], 'pnl': round(s['pnl'], 2), 'trades': s['trades'], 'win_rate': round(s['wins'] / s['trades'] * 100, 1) if s['trades'] > 0 else 0.0, } for day in sorted(by_day) for s in [by_day[day]] ] def hourly_pnl(deals: list[dict]) -> list[dict]: """P/L by close hour (0–23). Intended for --deep mode.""" by_hour: dict = defaultdict(lambda: {'pnl': 0.0, 'trades': 0, 'wins': 0}) for deal in deals: if 'out' not in deal.get('entry', '').lower(): continue profit = deal.get('profit', 0.0) if profit == 0.0: continue dt = _parse_dt(deal.get('time', '')) if not dt: continue by_hour[dt.hour]['pnl'] += profit by_hour[dt.hour]['trades'] += 1 if profit > 0: by_hour[dt.hour]['wins'] += 1 return [ { 'hour': h, 'pnl': round(s['pnl'], 2), 'trades': s['trades'], 'win_rate': round(s['wins'] / s['trades'] * 100, 1) if s['trades'] > 0 else 0.0, } for h in sorted(by_hour) for s in [by_hour[h]] ] def concurrent_peak(deals: list[dict]) -> dict: """Peak number of simultaneously open positions.""" events = [] for deal in deals: entry = deal.get('entry', '').lower() dt = _parse_dt(deal.get('time', '')) if not dt: continue if 'in' in entry and 'out' not in entry: events.append((dt, 1, deal)) elif 'out' in entry: events.append((dt, -1, deal)) events.sort(key=lambda x: x[0]) count = peak = 0 peak_time = '' for dt, delta, deal in events: count = max(0, count + delta) if count > peak: peak = count peak_time = deal.get('time', '') return {'peak_open': peak, 'peak_time': peak_time} def volume_profile(deals: list[dict]) -> list[dict]: """P/L breakdown by lot size tier. Intended for --deep mode.""" TIER_ORDER = ['0.01', '0.02-0.04', '0.05-0.09', '0.10-0.49', '0.50-0.99', '1.00+'] by_tier: dict = defaultdict(lambda: {'pnl': 0.0, 'trades': 0, 'wins': 0}) for deal in deals: if 'out' not in deal.get('entry', '').lower(): continue profit = deal.get('profit', 0.0) if profit == 0.0: continue tier = _lot_tier(deal.get('volume', 0.0)) by_tier[tier]['pnl'] += profit by_tier[tier]['trades'] += 1 if profit > 0: by_tier[tier]['wins'] += 1 return [ { 'lot_tier': tier, 'pnl': round(s['pnl'], 2), 'trades': s['trades'], 'win_rate': round(s['wins'] / s['trades'] * 100, 1) if s['trades'] > 0 else 0.0, } for tier in TIER_ORDER if tier in by_tier for s in [by_tier[tier]] ] # ── Strategy-aware analytics ────────────────────────────────────────────────── def depth_histogram(deals: list[dict], profile: dict) -> dict: """ Count how often each depth level was reached, using the profile's depth_re. Returns an empty dict when the profile has no depth_re (e.g. generic, scalper). For grid profiles, keys are L1 … L8+. """ depth_re = profile.get('depth_re') if not depth_re: return {} hist: dict[str, int] = {'L1': 0, 'L2': 0, 'L3': 0, 'L4': 0, 'L5': 0, 'L6': 0, 'L7': 0, 'L8+': 0} for deal in deals: depth = _extract_depth(deal.get('comment', ''), depth_re) if depth: key = f'L{depth}' if depth <= 7 else 'L8+' hist[key] = hist.get(key, 0) + 1 return hist def grid_depth_histogram(deals: list[dict]) -> dict: """Backward-compatible alias: depth_histogram using the grid profile.""" return depth_histogram(deals, PROFILES['grid']) def cycle_stats(deals: list[dict], profile: Optional[dict] = None) -> dict: """ Group deals into cycles using profile's cycle_group_by and cycle_gap_min. Returns overall win rate and win_rate_by_depth. Default profile (None) uses grid settings for backward compatibility. """ _profile = profile or PROFILES['grid'] group_by = _profile.get('cycle_group_by', 'magic+direction') gap_minutes = _profile.get('cycle_gap_min', 60) depth_re = _profile.get('depth_re') # active[key] = {'in_deals': [...], 'profit': float, 'max_depth': int, 'last_open': dt} active: dict[tuple, dict] = {} completed: list[dict] = [] def _key(deal: dict) -> tuple: magic = deal.get('magic', '') if group_by == 'magic+direction': return (magic, deal.get('type', '').lower()) return (magic,) def _flush(k: tuple) -> None: if k in active and active[k]['in_deals']: completed.append(active.pop(k)) for deal in sorted(deals, key=lambda d: _parse_dt(d.get('time', '')) or datetime.min): entry = deal.get('entry', '').lower() dt = _parse_dt(deal.get('time', '')) if not dt or not deal.get('magic', ''): continue key = _key(deal) depth = _extract_depth(deal.get('comment', ''), depth_re) if 'in' in entry and 'out' not in entry: if key in active: gap = (dt - active[key]['last_open']).total_seconds() / 60 if gap > gap_minutes: _flush(key) if key not in active: active[key] = {'in_deals': [], 'profit': 0.0, 'max_depth': 0, 'last_open': dt} active[key]['in_deals'].append(deal) active[key]['last_open'] = dt active[key]['max_depth'] = max(active[key]['max_depth'], depth) elif 'out' in entry: profit = deal.get('profit', 0.0) if profit == 0.0: continue if key in active: active[key]['profit'] += profit active[key]['last_open'] = dt for k in list(active.keys()): _flush(k) if not completed: return {'total_cycles': 0, 'win_rate': 0.0, 'avg_profit': 0.0, 'win_rate_by_depth': {}} total = len(completed) wins = sum(1 for c in completed if c['profit'] > 0) total_profit = sum(c['profit'] for c in completed) by_depth: dict[str, dict] = defaultdict(lambda: {'wins': 0, 'total': 0}) for c in completed: d = c['max_depth'] label = (f'L{d}' if 0 < d <= 7 else 'L8+') if d > 0 else 'L?' by_depth[label]['total'] += 1 if c['profit'] > 0: by_depth[label]['wins'] += 1 return { 'total_cycles': total, 'win_rate': round(wins / total * 100, 1), 'avg_profit': round(total_profit / total, 2), 'win_rate_by_depth': { d: {'total': s['total'], 'win_rate': round(s['wins'] / s['total'] * 100, 1)} for d, s in sorted(by_depth.items()) }, } def exit_reason_breakdown(deals: list[dict], profile: Optional[dict] = None) -> dict: """ Classify closed deals by exit reason and aggregate P/L. Default profile (None) uses grid keywords for backward compatibility. Generic profile returns only 'tp' / 'sl' (profit-sign classification). """ _profile = profile or PROFILES['grid'] counts: dict[str, int] = defaultdict(int) pnl: dict[str, float] = defaultdict(float) for deal in deals: if 'out' not in deal.get('entry', '').lower(): continue profit = deal.get('profit', 0.0) if profit == 0.0: continue reason = _classify_exit(deal.get('comment', ''), profit, _profile) counts[reason] += 1 pnl[reason] += profit return { reason: { 'count': counts[reason], 'total_pnl': round(pnl[reason], 2), 'avg_pnl': round(pnl[reason] / counts[reason], 2), } for reason in counts } # ── Summary ─────────────────────────────────────────────────────────────────── def build_summary(metrics: dict, monthly: list[dict], dd_events: list[dict], *, streak: Optional[dict] = None, bias: Optional[dict] = None, exits: Optional[dict] = None, cycles: Optional[dict] = None, strategy: Optional[str] = None) -> dict: green = sum(1 for m in monthly if m['green']) worst = min(monthly, key=lambda m: m['pnl'], default={}) summary: dict = { 'net_profit': metrics.get('net_profit', 0), 'profit_factor': metrics.get('profit_factor', 0), 'max_dd_pct': metrics.get('max_dd_pct', 0), 'sharpe_ratio': metrics.get('sharpe_ratio', 0), 'total_trades': metrics.get('total_trades', 0), 'recovery_factor': metrics.get('recovery_factor', 0), 'green_months': green, 'total_months': len(monthly), 'worst_month': worst.get('month', ''), 'worst_month_pnl': worst.get('pnl', 0), } if strategy: summary['strategy'] = strategy if streak: summary['max_win_streak'] = streak.get('max_win_streak', 0) summary['max_loss_streak'] = streak.get('max_loss_streak', 0) summary['current_streak'] = streak.get('current_streak', 0) summary['current_streak_type']= streak.get('current_streak_type', '') if bias: for direction in ('buy', 'sell'): if direction in bias: summary[f'{direction}_win_rate'] = bias[direction].get('win_rate', 0) summary[f'{direction}_total_pnl'] = bias[direction].get('total_pnl', 0) if exits: dominant = max(exits, key=lambda k: exits[k]['count'], default='') summary['dominant_exit'] = dominant if cycles and cycles.get('total_cycles', 0): summary['cycle_win_rate'] = cycles.get('win_rate', 0) summary['total_cycles'] = cycles.get('total_cycles', 0) return summary # ── Main ────────────────────────────────────────────────────────────────────── def _make_parser(prog_suffix: str = '') -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog=f'mt5-analyze{prog_suffix}' if prog_suffix else 'analyze.py', description=f'MT5 deal analysis{(" — " + PROFILES[prog_suffix.lstrip("-")]["name"]) if prog_suffix else ""}', ) p.add_argument('deals_csv', help='Path to deals.csv') p.add_argument('--output-dir', default='.', help='Output directory') p.add_argument('--deep', action='store_true', help='Add hourly_pnl and volume_profile') p.add_argument('--stdout', action='store_true', help='Print JSON to stdout') return p def _run(deals_csv: str, output_dir: str, deep: bool, stdout: bool, strategy_name: str) -> None: profile = PROFILES.get(strategy_name, PROFILES['grid']) os.makedirs(output_dir, exist_ok=True) deals = load_deals(deals_csv) metrics = load_metrics(os.path.join(output_dir, 'metrics.json')) if not deals: print("WARNING: No deals found in CSV", file=sys.stderr) # Generic analytics (always run, strategy-agnostic) monthly = monthly_pnl(deals) dd_ev = reconstruct_dd_events(deals, metrics, profile) top_loss = top_losses(deals) loss_seq = loss_sequences(deals) pairs = position_pairs(deals) bias = direction_bias(deals) streak = streak_analysis(deals) sessions = session_breakdown(deals) wday = weekday_pnl(deals) peak = concurrent_peak(deals) # Strategy-aware analytics depth_hist = depth_histogram(deals, profile) cycles = cycle_stats(deals, profile) exits = exit_reason_breakdown(deals, profile) summary = build_summary(metrics, monthly, dd_ev, streak=streak, bias=bias, exits=exits, cycles=cycles, strategy=strategy_name) analysis: dict = { 'strategy': strategy_name, 'summary': summary, 'monthly_pnl': monthly, 'dd_events': dd_ev, 'top_losses': top_loss, 'loss_sequences': loss_seq, 'position_pairs': pairs, 'direction_bias': bias, 'streak_analysis': streak, 'session_breakdown': sessions, 'weekday_pnl': wday, 'concurrent_peak': peak, 'depth_histogram': depth_hist, 'cycle_stats': cycles, 'exit_reason_breakdown': exits, } # Grid backward-compat alias if strategy_name == 'grid' and depth_hist: analysis['grid_depth_histogram'] = depth_hist if deep: analysis['hourly_pnl'] = hourly_pnl(deals) analysis['volume_profile'] = volume_profile(deals) if stdout: json.dump(analysis, sys.stdout, indent=2) print() return out_path = os.path.join(output_dir, 'analysis.json') with open(out_path, 'w') as f: json.dump(analysis, f, indent=2) print(f"Analysis complete [{PROFILES[strategy_name]['name']}]: {out_path}") print(f" {summary.get('green_months', 0)}/{summary.get('total_months', 0)} green months") print(f" {len(dd_ev)} DD events reconstructed") if depth_hist: max_layer = max( (k for k, v in depth_hist.items() if v > 0), key=lambda x: int(x[1:].replace('+', '9')), default='?' ) print(f" Grid depth: max {max_layer}") if cycles.get('total_cycles', 0): print(f" {cycles['total_cycles']} cycles — {cycles['win_rate']}% win rate") if bias: for d in ('buy', 'sell'): if d in bias: b = bias[d] print(f" {d.capitalize()}: {b['trades']} trades, " f"{b['win_rate']}% win rate, {b['total_pnl']:+.2f}") def main() -> None: """ Entry point — auto-detects subcommand style vs legacy style. analyze.py grid deals.csv [options] → grid strategy analyze.py deals.csv [options] → grid (backward compat default) """ strategy_name = 'grid' argv = sys.argv[1:] if argv and argv[0] in PROFILES: strategy_name = argv.pop(0) parser = _make_parser() args = parser.parse_args(argv) _run(args.deals_csv, args.output_dir, args.deep, args.stdout, strategy_name) # ── Named entry points for each strategy ───────────────────────────────────── def main_generic() -> None: """Entry point: mt5-analyze (generic, strategy-agnostic).""" _entry('generic') def main_grid() -> None: """Entry point: mt5-analyze-grid""" _entry('grid') def main_scalper() -> None: """Entry point: mt5-analyze-scalper""" _entry('scalper') def main_trend() -> None: """Entry point: mt5-analyze-trend""" _entry('trend') def main_hedge() -> None: """Entry point: mt5-analyze-hedge""" _entry('hedge') def _entry(strategy_name: str) -> None: parser = _make_parser(f'-{strategy_name}') args = parser.parse_args() _run(args.deals_csv, args.output_dir, args.deep, args.stdout, strategy_name) if __name__ == '__main__': main()