"""Backtest Engine — test trading strategies against historical price data.

Allows users to simulate trading strategies on historical OHLCV and
TechnicalAnalysis data to evaluate performance before deploying capital.

Supported strategies:
  1. score_based      — Buy when TA score > threshold, sell when < exit threshold
  2. rsi_reversal     — Buy when RSI < 30 (oversold), sell when RSI > 70
  3. trend_following   — Buy when ma_trend='bullish' AND score > 50, sell on bearish
  4. ml_consensus     — Buy when ML prediction + technical both bullish

Output includes full performance metrics: Sharpe, Sortino, Calmar ratios,
max drawdown, equity curve, monthly returns, benchmark comparison, and
individual trade log.
"""
from __future__ import annotations

import json
import logging
import math
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Optional

logger = logging.getLogger(__name__)

DEFAULT_INITIAL_CAPITAL = 100_000
DEFAULT_LOOKBACK_DAYS = 90
RISK_FREE_RATE = 0.04
TRADING_DAYS_PER_YEAR = 252
EQUITY_SAMPLE_INTERVAL = 7
MAX_TRADES_RETURNED = 50


def _safe_float(val, default: float = 0.0) -> float:
    """Safely convert a DB Numeric / Decimal to float."""
    if val is None:
        return default
    try:
        f = float(val)
        return default if (math.isnan(f) or math.isinf(f)) else f
    except (ValueError, TypeError):
        return default


def _safe_div(num: float, den: float, default: float = 0.0) -> float:
    """Division safe against zero and non-finite results."""
    if not den:
        return default
    r = num / den
    return default if (math.isnan(r) or math.isinf(r)) else r


class BacktestEngine:
    """Runs historical backtests for various trading strategies."""

    AVAILABLE_STRATEGIES = {
        'score_based': 'Score-Based (TA Score Threshold)',
        'rsi_reversal': 'RSI Reversal (Oversold/Overbought)',
        'trend_following': 'Trend Following (MA Trend + Score)',
        'ml_consensus': 'ML Consensus (ML + Technical Alignment)',
    }

    DEFAULT_PARAMS = {
        'score_based': {'buy_threshold': 70, 'sell_threshold': 40, 'max_positions': 5},
        'rsi_reversal': {'oversold': 30, 'overbought': 70, 'max_positions': 5},
        'trend_following': {'min_score': 50, 'max_positions': 5},
        'ml_consensus': {'min_score': 50, 'require_macd_bullish': True, 'max_positions': 5},
    }

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def run_backtest(self, strategy: str = 'score_based',
                     lookback_days: int = DEFAULT_LOOKBACK_DAYS,
                     params: Optional[dict] = None, limit: int = 50) -> dict:
        """Run a full backtest for *strategy* over *lookback_days*."""
        from app.extensions import db
        from app.helpers.asset_filter import get_asset_mode
        from app.models.asset import Asset, AssetProfile

        if strategy not in self.AVAILABLE_STRATEGIES:
            return {'error': f'Unknown strategy: {strategy}',
                    'available': list(self.AVAILABLE_STRATEGIES.keys()),
                    'timestamp': datetime.utcnow().isoformat()}

        merged = dict(self.DEFAULT_PARAMS.get(strategy, {}))
        if params:
            merged.update(params)

        end_date = datetime.utcnow()
        start_date = end_date - timedelta(days=lookback_days)

        try:
            assets = (Asset.query
                     .filter(Asset.is_active.is_(True), Asset.asset_type == get_asset_mode())
                     .order_by(Asset.market_cap_rank.asc())
                     .limit(max(limit, 1)).all())
            if not assets:
                return self._empty_result(strategy, merged, start_date, end_date)

            asset_ids = [c.id for c in assets]
            prices_map = self._load_price_data(asset_ids, start_date, end_date)
            ta_map = self._load_ta_data(asset_ids, start_date, end_date)
            if not prices_map:
                return self._empty_result(strategy, merged, start_date, end_date)

            handler = {'score_based': self._strategy_score_based,
                       'rsi_reversal': self._strategy_rsi_reversal,
                       'trend_following': self._strategy_trend_following,
                       'ml_consensus': self._strategy_ml_consensus}[strategy]

            signals = handler(prices_map, ta_map, merged)
            trades = self._simulate_trades(signals, prices_map)
            if not trades:
                return self._empty_result(strategy, merged, start_date, end_date)

            results = self._compute_metrics(trades, DEFAULT_INITIAL_CAPITAL)
            results['monthly_returns'] = self._build_monthly_returns(trades)
            results['equity_curve'] = self._build_equity_curve(trades, DEFAULT_INITIAL_CAPITAL)

            by_ret = sorted(trades, key=lambda t: t['return_pct'])
            results['best_trade'] = {
                'symbol': by_ret[-1].get('symbol', ''),
                'return_pct': round(by_ret[-1]['return_pct'], 4),
                'hold_days': by_ret[-1].get('hold_days', 0)}
            results['worst_trade'] = {
                'symbol': by_ret[0].get('symbol', ''),
                'return_pct': round(by_ret[0]['return_pct'], 4),
                'hold_days': by_ret[0].get('hold_days', 0)}

            trades_out = []
            for t in trades[:MAX_TRADES_RETURNED]:
                def _fmtd(d):
                    return d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d)
                trades_out.append({
                    'symbol': t.get('symbol', ''), 'entry_date': _fmtd(t['entry_date']),
                    'entry_price': round(_safe_float(t.get('entry_price')), 8),
                    'exit_date': _fmtd(t['exit_date']),
                    'exit_price': round(_safe_float(t.get('exit_price')), 8),
                    'return_pct': round(t.get('return_pct', 0), 4),
                    'hold_days': t.get('hold_days', 0),
                    'signal_at_entry': t.get('signal_at_entry', '')})

            return {
                'strategy_name': self.AVAILABLE_STRATEGIES[strategy],
                'params': merged,
                'period': {'start_date': start_date.strftime('%Y-%m-%d'),
                           'end_date': end_date.strftime('%Y-%m-%d')},
                'total_trades': len(trades),
                'results': results,
                'trades': trades_out,
                'benchmark': self._compute_benchmark(prices_map, start_date, end_date),
                'summary': self._generate_summary(results),
                'timestamp': datetime.utcnow().isoformat()}
        except Exception as exc:
            logger.exception('Backtest failed for strategy=%s: %s', strategy, exc)
            return {'error': str(exc),
                    'strategy_name': self.AVAILABLE_STRATEGIES.get(strategy, strategy),
                    'timestamp': datetime.utcnow().isoformat()}

    # ------------------------------------------------------------------
    # Data loaders
    # ------------------------------------------------------------------

    def _load_price_data(self, asset_ids, start_date, end_date) -> dict:
        """Load price data from AssetProfile sparkline into {asset_id: [{date,open,high,low,close,volume}]}.

        Sparkline provides ~168 hourly data points (7 days).  We bucket them
        into daily OHLCV-like records so the rest of the backtest pipeline
        works unchanged.
        """
        from app.models.asset import Asset
        from app.helpers.sparkline import load_sparkline

        try:
            assets = Asset.query.filter(Asset.id.in_(asset_ids)).all()
        except Exception as exc:
            logger.warning('Failed to load assets for price data: %s', exc)
            return {}

        out: dict = {}
        now = datetime.utcnow()
        for asset in assets:
            prices = load_sparkline(asset, min_points=24)
            if not prices:
                continue
            # Build daily records from hourly prices (24 points per day)
            daily: list = []
            hours_per_day = 24
            num_points = len(prices)
            for day_start in range(0, num_points, hours_per_day):
                chunk = prices[day_start:day_start + hours_per_day]
                if not chunk:
                    continue
                # Assign a date based on offset from now
                days_ago = (num_points - day_start) // hours_per_day
                d = (now - timedelta(days=days_ago)).date()
                if d < start_date.date() or d > end_date.date():
                    continue
                daily.append({
                    'date': d,
                    'open': chunk[0],
                    'high': max(chunk),
                    'low': min(chunk),
                    'close': chunk[-1],
                    'volume': 0.0,
                })
            if daily:
                out[asset.id] = daily
        return out

    def _load_ta_data(self, asset_ids, start_date, end_date) -> dict:
        """Load TechnicalAnalysis rows into {asset_id: [{date,score,signal,rsi,...}]}."""
        from app.models.technical_analysis import TechnicalAnalysis
        try:
            rows = (TechnicalAnalysis.query
                    .filter(TechnicalAnalysis.asset_id.in_(asset_ids),
                            TechnicalAnalysis.created_at >= start_date,
                            TechnicalAnalysis.created_at <= end_date)
                    .order_by(TechnicalAnalysis.asset_id, TechnicalAnalysis.created_at.asc()).all())
        except Exception as exc:
            logger.warning('Failed to load TechnicalAnalysis: %s', exc)
            return {}
        out: dict = defaultdict(list)
        for r in rows:
            d = r.created_at.date() if isinstance(r.created_at, datetime) else r.created_at
            out[r.asset_id].append({
                'date': d, 'score': _safe_float(r.score),
                'signal': getattr(r, 'signal', None), 'rsi': _safe_float(r.rsi),
                'macd_signal': getattr(r, 'macd_signal', None),
                'ma_trend': getattr(r, 'ma_trend', None)})
        return dict(out)

    # ------------------------------------------------------------------
    # Shared strategy helper: build indexes & iterate timeline
    # ------------------------------------------------------------------

    def _build_indexes(self, prices_map, ta_map):
        """Build date-sorted timeline and fast (asset_id, date) look-ups."""
        all_dates = set()
        ta_index, price_index = {}, {}
        for cid, rows in ta_map.items():
            for r in rows:
                all_dates.add(r['date'])
                ta_index[(cid, r['date'])] = r
        for cid, rows in prices_map.items():
            for r in rows:
                price_index[(cid, r['date'])] = r
        return sorted(all_dates), ta_index, price_index

    def _force_close(self, open_pos, prices_map, signals):
        """Append SELL signals for any still-open positions at period end."""
        for cid in list(open_pos.keys()):
            rows = prices_map.get(cid, [])
            if rows:
                signals.append({'asset_id': cid, 'date': rows[-1]['date'],
                                'action': 'SELL', 'price': rows[-1]['close'],
                                'meta': 'force_close_end_of_period'})

    # ------------------------------------------------------------------
    # Strategy implementations
    # ------------------------------------------------------------------

    def _strategy_score_based(self, prices_map, ta_map, params) -> list:
        """Buy when TA score > buy_threshold, sell when < sell_threshold."""
        buy_th = params.get('buy_threshold', 70)
        sell_th = params.get('sell_threshold', 40)
        max_pos = params.get('max_positions', 5)
        dates, ta_ix, px_ix = self._build_indexes(prices_map, ta_map)
        signals, open_pos = [], {}

        for d in dates:
            for cid in ta_map:
                ta = ta_ix.get((cid, d))
                px = px_ix.get((cid, d))
                if not ta or not px or px['close'] <= 0:
                    continue
                score = ta['score']
                if cid in open_pos:
                    if score < sell_th:
                        signals.append({'asset_id': cid, 'date': d, 'action': 'SELL',
                                        'price': px['close'],
                                        'meta': f'score={score:.1f}<{sell_th}'})
                        del open_pos[cid]
                elif len(open_pos) < max_pos and score > buy_th:
                    signals.append({'asset_id': cid, 'date': d, 'action': 'BUY',
                                    'price': px['close'],
                                    'meta': f'score={score:.1f}>{buy_th}'})
                    open_pos[cid] = True

        self._force_close(open_pos, prices_map, signals)
        return signals

    def _strategy_rsi_reversal(self, prices_map, ta_map, params) -> list:
        """Buy when RSI < oversold, sell when RSI > overbought."""
        oversold = params.get('oversold', 30)
        overbought = params.get('overbought', 70)
        max_pos = params.get('max_positions', 5)
        dates, ta_ix, px_ix = self._build_indexes(prices_map, ta_map)
        signals, open_pos = [], {}

        for d in dates:
            for cid in ta_map:
                ta = ta_ix.get((cid, d))
                px = px_ix.get((cid, d))
                if not ta or not px or px['close'] <= 0:
                    continue
                rsi = ta.get('rsi', 50)
                if not rsi:
                    continue
                if cid in open_pos:
                    if rsi > overbought:
                        signals.append({'asset_id': cid, 'date': d, 'action': 'SELL',
                                        'price': px['close'],
                                        'meta': f'rsi={rsi:.1f}>{overbought}'})
                        del open_pos[cid]
                elif len(open_pos) < max_pos and rsi < oversold:
                    signals.append({'asset_id': cid, 'date': d, 'action': 'BUY',
                                    'price': px['close'],
                                    'meta': f'rsi={rsi:.1f}<{oversold}'})
                    open_pos[cid] = True

        self._force_close(open_pos, prices_map, signals)
        return signals

    def _strategy_trend_following(self, prices_map, ta_map, params) -> list:
        """Buy on bullish ma_trend + score > min_score; sell on bearish."""
        min_score = params.get('min_score', 50)
        max_pos = params.get('max_positions', 5)
        dates, ta_ix, px_ix = self._build_indexes(prices_map, ta_map)
        signals, open_pos = [], {}

        for d in dates:
            for cid in ta_map:
                ta = ta_ix.get((cid, d))
                px = px_ix.get((cid, d))
                if not ta or not px or px['close'] <= 0:
                    continue
                trend = (ta.get('ma_trend') or '').lower().strip()
                score = ta['score']
                if cid in open_pos:
                    if trend == 'bearish':
                        signals.append({'asset_id': cid, 'date': d, 'action': 'SELL',
                                        'price': px['close'],
                                        'meta': f'ma=bearish,score={score:.1f}'})
                        del open_pos[cid]
                elif len(open_pos) < max_pos and trend == 'bullish' and score > min_score:
                    signals.append({'asset_id': cid, 'date': d, 'action': 'BUY',
                                    'price': px['close'],
                                    'meta': f'ma=bullish,score={score:.1f}'})
                    open_pos[cid] = True

        self._force_close(open_pos, prices_map, signals)
        return signals

    def _strategy_ml_consensus(self, prices_map, ta_map, params) -> list:
        """Buy when ML + technical both bullish; sell when either turns bearish."""
        min_score = params.get('min_score', 50)
        req_macd = params.get('require_macd_bullish', True)
        max_pos = params.get('max_positions', 5)
        dates, ta_ix, px_ix = self._build_indexes(prices_map, ta_map)
        signals, open_pos = [], {}

        for d in dates:
            for cid in ta_map:
                ta = ta_ix.get((cid, d))
                px = px_ix.get((cid, d))
                if not ta or not px or px['close'] <= 0:
                    continue
                trend = (ta.get('ma_trend') or '').lower().strip()
                macd = (ta.get('macd_signal') or '').lower().strip()
                score = ta['score']
                if cid in open_pos:
                    if macd == 'bearish' or trend == 'bearish':
                        signals.append({'asset_id': cid, 'date': d, 'action': 'SELL',
                                        'price': px['close'],
                                        'meta': f'macd={macd},ma={trend}'})
                        del open_pos[cid]
                elif len(open_pos) < max_pos:
                    macd_ok = (not req_macd) or (macd == 'bullish')
                    if trend == 'bullish' and macd_ok and score > min_score:
                        signals.append({'asset_id': cid, 'date': d, 'action': 'BUY',
                                        'price': px['close'],
                                        'meta': f'macd={macd},ma={trend},score={score:.1f}'})
                        open_pos[cid] = True

        self._force_close(open_pos, prices_map, signals)
        return signals

    # ------------------------------------------------------------------
    # Trade simulation
    # ------------------------------------------------------------------

    def _simulate_trades(self, signals: list, prices_map: dict) -> list:
        """Pair BUY/SELL signals into completed trades with returns."""
        from app.models.asset import Asset

        by_coin: dict = defaultdict(list)
        for s in signals:
            by_coin[s['asset_id']].append(s)

        sym_map: dict = {}
        cids = list(by_coin.keys())
        if cids:
            try:
                sym_map = {c.id: c.symbol.upper()
                           for c in Asset.query.filter(Asset.id.in_(cids)).all()}
            except Exception:
                pass

        trades = []
        for cid, sigs in by_coin.items():
            pending = None
            for s in sorted(sigs, key=lambda x: x['date']):
                if s['action'] == 'BUY' and pending is None:
                    pending = s
                elif s['action'] == 'SELL' and pending is not None:
                    ep, xp = pending['price'], s['price']
                    if ep <= 0:
                        pending = None
                        continue
                    ed = pending['date']
                    xd = s['date']
                    try:
                        ed2 = ed.date() if isinstance(ed, datetime) else ed
                        xd2 = xd.date() if isinstance(xd, datetime) else xd
                        hd = max((xd2 - ed2).days, 0)
                    except (TypeError, AttributeError):
                        hd = 0
                    trades.append({
                        'asset_id': cid, 'symbol': sym_map.get(cid, cid),
                        'entry_date': ed, 'entry_price': ep,
                        'exit_date': xd, 'exit_price': xp,
                        'return_pct': round(((xp - ep) / ep) * 100, 4),
                        'hold_days': hd,
                        'signal_at_entry': pending.get('meta', '')})
                    pending = None

        trades.sort(key=lambda t: t.get('entry_date') or datetime.min)
        return trades

    # ------------------------------------------------------------------
    # Performance metrics
    # ------------------------------------------------------------------

    def _compute_metrics(self, trades, initial_capital=DEFAULT_INITIAL_CAPITAL) -> dict:
        """Compute full performance metrics from completed trades."""
        if not trades:
            return self._empty_metrics()

        rets = [t['return_pct'] for t in trades]
        holds = [t.get('hold_days', 0) for t in trades]
        wins = [r for r in rets if r > 0]
        losses = [r for r in rets if r < 0]
        n = len(rets)

        # Compounded total return
        eq = initial_capital
        for r in rets:
            eq *= (1 + r / 100)
        total_ret = round(((eq - initial_capital) / initial_capital) * 100, 4)

        win_rate = round((len(wins) / n) * 100, 2) if n else 0.0
        avg_win = round(sum(wins) / len(wins), 4) if wins else 0.0
        avg_loss = round(sum(losses) / len(losses), 4) if losses else 0.0
        gross_p = sum(wins) if wins else 0.0
        gross_l = abs(sum(losses)) if losses else 0.0
        pf = round(_safe_div(gross_p, gross_l), 4)

        eq_series = self._build_equity_series(rets, initial_capital)
        max_dd = round(self._compute_drawdown(eq_series), 4)
        sharpe = round(self._compute_sharpe(rets), 4)
        sortino = round(self._compute_sortino(rets), 4)

        total_days = max(sum(holds), 1)
        ann_ret = (total_ret / total_days) * 365
        calmar = round(_safe_div(ann_ret, abs(max_dd)), 4) if max_dd else 0.0
        avg_hold = round(sum(holds) / len(holds), 1) if holds else 0.0

        return {'total_return_pct': total_ret, 'win_rate': win_rate,
                'avg_win_pct': avg_win, 'avg_loss_pct': avg_loss,
                'profit_factor': pf, 'max_drawdown_pct': max_dd,
                'sharpe_ratio': sharpe, 'sortino_ratio': sortino,
                'calmar_ratio': calmar, 'avg_hold_days': avg_hold}

    def _build_equity_series(self, returns, capital) -> list:
        """List of equity values after each trade."""
        series = [capital]
        eq = capital
        for r in returns:
            eq *= (1 + r / 100)
            series.append(eq)
        return series

    def _compute_drawdown(self, equity_curve: list) -> float:
        """Max peak-to-trough drawdown percentage (positive number)."""
        if len(equity_curve) < 2:
            return 0.0
        peak = equity_curve[0]
        max_dd = 0.0
        for v in equity_curve:
            if v > peak:
                peak = v
            if peak > 0:
                dd = ((peak - v) / peak) * 100
                if dd > max_dd:
                    max_dd = dd
        return max_dd

    def _compute_sharpe(self, returns, risk_free=RISK_FREE_RATE) -> float:
        """Annualised Sharpe ratio from per-trade returns."""
        if len(returns) < 2:
            return 0.0
        n = len(returns)
        mean_r = sum(returns) / n
        per_trade_rf = (risk_free / TRADING_DAYS_PER_YEAR) * 100
        excess = [r - per_trade_rf for r in returns]
        mean_ex = sum(excess) / n
        var = sum((r - mean_r) ** 2 for r in returns) / (n - 1)
        sd = math.sqrt(var) if var > 0 else 0.0
        if sd == 0:
            return 0.0
        return (mean_ex / sd) * math.sqrt(TRADING_DAYS_PER_YEAR)

    def _compute_sortino(self, returns, risk_free=RISK_FREE_RATE) -> float:
        """Annualised Sortino ratio (downside deviation only)."""
        if len(returns) < 2:
            return 0.0
        n = len(returns)
        per_trade_rf = (risk_free / TRADING_DAYS_PER_YEAR) * 100
        excess = [r - per_trade_rf for r in returns]
        mean_ex = sum(excess) / n
        down_sq = [e ** 2 for e in excess if e < 0]
        if not down_sq:
            return 10.0 if mean_ex > 0 else 0.0
        dd = math.sqrt(sum(down_sq) / len(down_sq))
        if dd == 0:
            return 0.0
        return (mean_ex / dd) * math.sqrt(TRADING_DAYS_PER_YEAR)

    # ------------------------------------------------------------------
    # Equity curve, benchmark, monthly returns
    # ------------------------------------------------------------------

    def _build_equity_curve(self, trades, initial_capital) -> list:
        """Dated equity curve sampled every EQUITY_SAMPLE_INTERVAL days."""
        if not trades:
            return []
        all_dt = []
        for t in trades:
            for k in ('entry_date', 'exit_date'):
                v = t.get(k)
                if v:
                    all_dt.append(v if isinstance(v, datetime)
                                  else datetime.combine(v, datetime.min.time()))
        if not all_dt:
            return []

        mn, mx = min(all_dt), max(all_dt)
        pnl_by_date: dict = defaultdict(float)
        for t in trades:
            xd = t.get('exit_date')
            if xd is None:
                continue
            if not isinstance(xd, datetime):
                xd = datetime.combine(xd, datetime.min.time())
            pnl_by_date[xd.date()] += t.get('return_pct', 0.0)

        eq, curve = initial_capital, []
        cur, last = mn, None
        while cur <= mx:
            d = cur.date() if isinstance(cur, datetime) else cur
            p = pnl_by_date.get(d, 0.0)
            if p:
                eq *= (1 + p / 100)
            if last is None or (d - last).days >= EQUITY_SAMPLE_INTERVAL:
                curve.append({'date': d.strftime('%Y-%m-%d'), 'equity': round(eq, 2)})
                last = d
            cur += timedelta(days=1)

        fin = (mx.date() if isinstance(mx, datetime) else mx).strftime('%Y-%m-%d')
        if not curve or curve[-1]['date'] != fin:
            curve.append({'date': fin, 'equity': round(eq, 2)})
        return curve

    def _compute_benchmark(self, prices_map, start_date, end_date) -> dict:
        """Equal-weight buy-and-hold benchmark: return_pct and sharpe."""
        if not prices_map:
            return {'return_pct': 0.0, 'sharpe': 0.0}
        coin_rets, daily_rets = [], []
        for cid, rows in prices_map.items():
            if len(rows) < 2:
                continue
            fp, lp = rows[0]['close'], rows[-1]['close']
            if fp <= 0:
                continue
            coin_rets.append(((lp - fp) / fp) * 100)
            for i in range(1, len(rows)):
                pc = rows[i - 1]['close']
                if pc > 0:
                    daily_rets.append(((rows[i]['close'] - pc) / pc) * 100)
        if not coin_rets:
            return {'return_pct': 0.0, 'sharpe': 0.0}
        avg_r = round(sum(coin_rets) / len(coin_rets), 4)
        bm_sharpe = 0.0
        if len(daily_rets) >= 2:
            n = len(daily_rets)
            md = sum(daily_rets) / n
            rf_d = (RISK_FREE_RATE / 365) * 100
            var = sum((r - md) ** 2 for r in daily_rets) / (n - 1)
            sd = math.sqrt(var) if var > 0 else 0.0
            if sd > 0:
                bm_sharpe = round(((md - rf_d) / sd) * math.sqrt(TRADING_DAYS_PER_YEAR), 4)
        return {'return_pct': avg_r, 'sharpe': bm_sharpe}

    def _build_monthly_returns(self, trades) -> list:
        """Group trade returns by exit-month."""
        if not trades:
            return []
        monthly: dict = defaultdict(float)
        for t in trades:
            xd = t.get('exit_date')
            if xd is None:
                continue
            mk = xd.strftime('%Y-%m') if hasattr(xd, 'strftime') else str(xd)[:7]
            monthly[mk] += t.get('return_pct', 0.0)
        return [{'month': m, 'return_pct': round(v, 4)} for m, v in sorted(monthly.items())]

    # ------------------------------------------------------------------
    # Summary
    # ------------------------------------------------------------------

    def _generate_summary(self, results: dict) -> str:
        """Human-readable text summary of backtest results."""
        tr = results.get('total_return_pct', 0.0)
        wr = results.get('win_rate', 0.0)
        pf = results.get('profit_factor', 0.0)
        dd = results.get('max_drawdown_pct', 0.0)
        sh = results.get('sharpe_ratio', 0.0)
        so = results.get('sortino_ratio', 0.0)
        ah = results.get('avg_hold_days', 0.0)

        if tr > 20 and sh > 1.5:
            rating = 'Excellent'
        elif tr > 10 and sh > 1.0:
            rating = 'Good'
        elif tr > 0:
            rating = 'Moderate'
        elif tr > -10:
            rating = 'Poor'
        else:
            rating = 'Very Poor'

        lines = [f'Performance Rating: {rating}',
                 f'Total Return: {tr:+.2f}%', f'Win Rate: {wr:.1f}%',
                 f'Profit Factor: {pf:.2f}', f'Max Drawdown: -{dd:.2f}%',
                 f'Sharpe Ratio: {sh:.2f}', f'Sortino Ratio: {so:.2f}',
                 f'Avg Holding Period: {ah:.1f} days']

        if dd > 30:
            lines.append('WARNING: High drawdown risk (>30%). Consider tighter stops.')
        elif dd > 20:
            lines.append('CAUTION: Moderate drawdown risk (>20%). Monitor position sizing.')
        if wr < 40:
            lines.append('NOTE: Low win rate. Strategy relies on large winners.')
        elif wr > 70:
            lines.append('NOTE: High win rate. Verify not curve-fitted to historical data.')
        if 0 < pf < 1.0:
            lines.append('WARNING: Profit factor below 1.0 — strategy loses money overall.')
        return '\n'.join(lines)

    # ------------------------------------------------------------------
    # Empty / fallback
    # ------------------------------------------------------------------

    def _empty_metrics(self) -> dict:
        return {'total_return_pct': 0.0, 'win_rate': 0.0, 'avg_win_pct': 0.0,
                'avg_loss_pct': 0.0, 'profit_factor': 0.0, 'max_drawdown_pct': 0.0,
                'sharpe_ratio': 0.0, 'sortino_ratio': 0.0, 'calmar_ratio': 0.0,
                'avg_hold_days': 0.0,
                'best_trade': {'symbol': '', 'return_pct': 0.0, 'hold_days': 0},
                'worst_trade': {'symbol': '', 'return_pct': 0.0, 'hold_days': 0},
                'monthly_returns': [], 'equity_curve': []}

    def _empty_result(self, strategy, params, start_date, end_date) -> dict:
        return {'strategy_name': self.AVAILABLE_STRATEGIES.get(strategy, strategy),
                'params': params,
                'period': {'start_date': start_date.strftime('%Y-%m-%d'),
                           'end_date': end_date.strftime('%Y-%m-%d')},
                'total_trades': 0, 'results': self._empty_metrics(), 'trades': [],
                'benchmark': {'return_pct': 0.0, 'sharpe': 0.0},
                'summary': 'No trades generated. Insufficient data or no signals met strategy criteria.',
                'timestamp': datetime.utcnow().isoformat()}
