"""向量化 + 事件循环混合的回测引擎。

关键假设（务必知悉，均可在配置中调整）：
- T 日收盘产生信号，T+1 起按新权重计价（无前向偏差）；
- 佣金/印花税/滑点在换手时一次性扣除；
- 持仓期间权重按每日收益自然漂移，不做日内再平衡；
- 波动率目标与回撤降仓为组合层面风控覆盖层。
"""
from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np
import pandas as pd


def build_matrices(data_map: Dict[str, pd.DataFrame]):
    """拼装收盘价 / 成交额矩阵。"""
    close = pd.DataFrame({c: df["close"] for c, df in data_map.items()})
    amount = pd.DataFrame({c: df["close"] * df["volume"] for c, df in data_map.items()})
    close = close.sort_index()
    amount = amount.sort_index().reindex(close.index)
    return close, amount


class Backtester:
    """多因子选股回测器。"""

    def __init__(self, strategy: Dict):
        self.cfg = strategy
        sel = strategy["selection"]
        self.factors = sel["factors"]
        self.lookbacks = sel["factor_lookbacks"]
        self.max_positions = sel["max_positions"]
        self.min_history = sel["min_history"]
        port = strategy["portfolio"]
        self.rebalance_days = port["rebalance_days"]
        self.cash_buffer = port["cash_buffer"]
        self.max_weight = port["max_weight"]
        self.fwd_days = port["fwd_days"]
        rc = strategy["risk_control"]
        self.target_vol = rc["target_vol"]
        self.vol_lookback = rc["vol_lookback"]
        self.max_dd_derisk = rc["max_drawdown_derisk"]
        self.de_risk_ratio = rc["de_risk_ratio"]
        self.trend_filter = rc["trend_filter"]
        self.trend_ma = rc["trend_filter_ma"]
        ex = strategy["execution"]
        self.cost_buy = ex["commission"] + ex["slippage"]
        self.cost_sell = ex["commission"] + ex["stamp_tax_sell"] + ex["slippage"]

    # ------------------------------------------------------------------
    def run(self, data_map: Dict[str, pd.DataFrame], benchmark_close: Optional[pd.Series] = None) -> Dict:
        from .factors import compute_factor_panel, cross_section_z

        close, amount = build_matrices(data_map)
        dates = close.index
        rets = close.pct_change(fill_method=None)
        ma = close.rolling(self.trend_ma, min_periods=self.trend_ma // 2).mean()

        # 预计算各因子截面 Z-score 面板
        z_panels: Dict[str, pd.DataFrame] = {}
        for f, w in self.factors.items():
            if w <= 0:
                continue
            panel = compute_factor_panel(data_map, f, self.lookbacks[f], dates)
            z_panels[f] = cross_section_z(panel)
        score = sum(w * z_panels[f] for f, w in self.factors.items() if w > 0)

        start = pd.Timestamp(self.cfg["backtest_window"]["start"])
        end = pd.Timestamp(self.cfg["backtest_window"]["end"])
        bdates = [d for d in dates if start <= d <= end]
        if len(bdates) < 60:
            raise ValueError("回测区间太短，请扩大 start/end 或加大数据抓取范围")

        equity = 1.0
        curve, bench_curve, exposure_hist = [], [], []
        weights: Dict[str, float] = {}
        cash = 1.0
        peak = 1.0
        de_risked = False
        strat_rets: List[float] = []
        rebalance_log: List[Dict] = []
        turnover_hist: List[float] = []

        prev_target: Dict[str, float] = {}
        pending_target: Optional[Dict[str, float]] = None
        pending_expo = 1.0

        for i, d in enumerate(bdates):
            di = dates.get_loc(d)

            # ---- 1) 当日收益结算（用昨日收盘确定的持仓） ----
            if i > 0:
                day_ret = 0.0
                for c, w in weights.items():
                    r = rets.at[d, c]
                    if not np.isnan(r):
                        day_ret += w * r
                day_ret += cash * 0.0
                # 换仓日：结算与成本同日发生（近似：以 T+1 收盘换手计价）
                if pending_target is not None:
                    tgt, expo = pending_target, pending_expo
                    old_w = {c: w for c, w in weights.items()}
                    old_cash = cash
                    # 合并当日收益后的旧权重
                    denom = 1.0 + day_ret
                    drifted = {c: w * (1.0 + (rets.at[d, c] if not np.isnan(rets.at[d, c]) else 0.0)) / denom
                               for c, w in old_w.items()}
                    drifted_cash = old_cash / denom
                    buy = sum(max(tgt.get(c, 0.0) - drifted.get(c, 0.0), 0.0) for c in set(tgt) | set(drifted))
                    sell = sum(max(drifted.get(c, 0.0) - tgt.get(c, 0.0), 0.0) for c in set(tgt) | set(drifted))
                    cost = buy * self.cost_buy + sell * self.cost_sell
                    day_ret += -cost
                    weights = {c: w / (1.0 + 0.0) for c, w in tgt.items()}
                    cash = 1.0 - sum(weights.values())
                    turnover_hist.append(buy + sell)
                    pending_target = None
                    if day_ret < -0.5:
                        day_ret = -0.5  # 物理下限保护
                equity *= (1.0 + day_ret)
                strat_rets.append(day_ret)
                peak = max(peak, equity)

            # ---- 2) 调仓日：生成新目标权重（T 日收盘，T+1 生效） ----
            is_rebalance = (i % self.rebalance_days == 0)
            if is_rebalance:
                scores = score.loc[d].dropna()
                eligible = scores.index.tolist()
                # 最小历史长度
                hist_ok = [c for c in eligible
                           if data_map[c].index[0] <= d - pd.Timedelta(days=self.min_history)]
                eligible = hist_ok
                if self.trend_filter:
                    ma_d = ma.loc[d]
                    eligible = [c for c in eligible
                                if not np.isnan(ma_d.get(c, np.nan)) and close.at[d, c] >= ma_d[c]]
                ranked = sorted(eligible, key=lambda c: scores[c], reverse=True)
                selected = ranked[: self.max_positions]
                expo = 1.0
                if len(strat_rets) >= self.vol_lookback:
                    rv = float(np.std(strat_rets[-self.vol_lookback:], ddof=1) * np.sqrt(252))
                    if rv > 1e-9:
                        expo = min(1.0, self.target_vol / rv)
                dd = 1.0 - equity / peak
                if dd > self.max_dd_derisk:
                    expo *= self.de_risk_ratio
                    de_risked = True
                elif de_risked and dd < self.max_dd_derisk * 0.5:
                    de_risked = False

                base = (1.0 - self.cash_buffer) * expo
                if selected:
                    w_each = min(self.max_weight, base / len(selected))
                    tgt = {c: w_each for c in selected}
                else:
                    tgt = {}
                pending_target = tgt
                pending_expo = 1.0  # expo 已并入 base
                exposure_hist.append({"date": str(d.date()), "exposure": base, "de_risked": de_risked})
                rebalance_log.append({
                    "date": str(d.date()),
                    "n_eligible": len(eligible),
                    "selected": [{"code": c, "name": "", "score": round(float(scores[c]), 3),
                                  "weight": round(tgt.get(c, 0.0), 4)} for c in selected],
                })

            curve.append({"date": str(d.date()), "equity": equity})
            if benchmark_close is not None:
                bv = benchmark_close.reindex([d]).dropna()
                bench_curve.append({"date": str(d.date()),
                                    "equity": float(bv.iloc[0]) / float(benchmark_close.dropna().iloc[0]) if len(bv) else np.nan})

        curve_df = pd.DataFrame(curve).set_index("date")
        curve_df.index = pd.to_datetime(curve_df.index)
        bench_df = pd.DataFrame(bench_curve).dropna().set_index("date")
        if len(bench_df):
            bench_df.index = pd.to_datetime(bench_df.index)
            bench_df = bench_df / bench_df["equity"].iloc[0]

        # ---- IC 序列（合成因子 vs fwd_days 前向收益，Spearman） ----
        fwd = close.shift(-self.fwd_days) / close - 1.0
        ic_series = {}
        for entry in rebalance_log:
            d = pd.Timestamp(entry["date"])
            if d not in score.index:
                continue
            s = score.loc[d].dropna()
            f = fwd.loc[d].reindex(s.index).dropna()
            common = s.index.intersection(f.index)
            if len(common) >= 8:
                # Spearman = 秩的 Pearson（避免引入 scipy 依赖）
                ic_series[str(d.date())] = float(s[common].rank().corr(f[common].rank()))

        results = {
            "curve": curve_df,
            "benchmark": bench_df,
            "strat_rets": pd.Series(strat_rets, index=curve_df.index[1:]) if strat_rets else pd.Series(dtype=float),
            "rebalance_log": rebalance_log,
            "exposure_hist": exposure_hist,
            "turnover_hist": turnover_hist,
            "ic_series": ic_series,
            "latest_weights": weights,
        }
        return results
