"""下个周期推荐（CLI）。

与回测的区别：回测回答"这套规则历史上表现如何"，本工具回答
"按这套因子与周期，**下个周期该买哪几只、各配多少**"。

用法：
  python recommend.py --period 月
  python recommend.py --period 周 --style momentum=0.3,reversal=0.3,trend=0.4 --top 10
  python recommend.py --days 15 --risk 进取 --dashboard out/dashboard.html
"""
from __future__ import annotations

import argparse
import json
import os
import sys

import numpy as np
import pandas as pd

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from quantlab import dataloader as dl
from quantlab.factors import cross_section_z, FACTOR_FUNCS
from quantlab.periods import PERIOD_PROFILE, PERIOD_DAYS, resolve_period
from quantlab.strategy_generator import DEFAULT_STYLE, RISK_LEVELS, normalize_style

ROOT = os.path.dirname(os.path.abspath(__file__))
QLAB_DATA = os.path.join(ROOT, "quantlab", "data")
DATA_START = "2021-06-01"

# 需要按周期切换回看窗口的因子
PERIODIC_FACTORS = {"momentum": "mom", "trend": "trend"}
# 固定回看窗口的因子
FIXED_LOOKBACK = {"quality_momentum": 120, "reversal": 5, "low_vol": 60, "high_52w": 250, "liquidity": 20}
# 快照中需要预计算的动量/趋势窗口（覆盖全部周期档位）
MOM_WINDOWS = [20, 60, 120, 180, 250]
TREND_WINDOWS = [20, 60, 120, 200, 250]

FACTOR_LABELS = {
    "momentum": "动量",
    "quality_momentum": "质量动量",
    "low_vol": "低波",
    "trend": "趋势",
    "reversal": "短期反转",
    "high_52w": "52周高点",
    "liquidity": "流动性",
}


def load_names() -> dict:
    names = {}
    path = os.path.join(QLAB_DATA, "universe_sample.csv")
    if os.path.exists(path):
        with open(path, encoding="utf-8-sig") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or line.startswith("code,"):
                    continue
                parts = line.split(",", 1)
                if len(parts) == 2:
                    names[parts[0]] = parts[1]
    return names


def load_codes() -> list:
    codes = []
    path = os.path.join(QLAB_DATA, "universe_sample.csv")
    with open(path, encoding="utf-8-sig") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#") or line.startswith("code,"):
                continue
            codes.append(line.split(",")[0])
    return codes


def build_snapshot(data_map: dict, asof: str = None) -> dict:
    """在 asof 日（默认全池最新共同交易日）计算每只股票的因子值快照。"""
    # 选择 asof：优先用出现频率最高的最新日期
    last_dates = [df.index.max() for df in data_map.values()]
    cutoff = max(last_dates) if not asof else pd.Timestamp(asof)
    counts = {}
    for df in data_map.values():
        if cutoff in df.index:
            counts[cutoff] = counts.get(cutoff, 0) + 1
        else:
            prev = df.index[df.index <= cutoff]
            if len(prev):
                d = prev[-1]
                counts[d] = counts.get(d, 0) + 1
    if not counts:
        raise ValueError("无可用交易日数据")
    # 取"最近且覆盖标的数达到最大值 90%"的日期
    max_cov = max(counts.values())
    candidates = sorted([d for d, c in counts.items() if c >= max_cov * 0.9])
    asof_date = candidates[-1]
    if asof and pd.Timestamp(asof) < asof_date:
        asof_date = pd.Timestamp(asof)

    stocks = []
    for code, df in data_map.items():
        sub = df.loc[:asof_date]
        if len(sub) < 260:
            continue
        close = sub["close"]
        ret = close.pct_change()
        ma200 = close.rolling(200, min_periods=150).mean().iloc[-1]
        row = {
            "code": code,
            "close": round(float(close.iloc[-1]), 2),
            "ma200_dev": round(float(close.iloc[-1] / ma200 - 1.0), 4) if ma200 == ma200 else None,
            "vol60": round(float(ret.tail(60).std() * np.sqrt(252)), 4),
        }
        for w in MOM_WINDOWS:
            key = f"mom{w}"
            if len(close) > w + 5:
                row[key] = round(float(close.iloc[-6] / close.iloc[-6 - w] - 1.0), 4)
            else:
                row[key] = None
        for w in TREND_WINDOWS:
            key = f"trend{w}"
            if len(close) > w:
                ma = close.rolling(w, min_periods=max(10, w // 3)).mean().iloc[-1]
                row[key] = round(float(close.iloc[-1] / ma - 1.0), 4) if ma == ma else None
            else:
                row[key] = None
        # 固定窗口因子
        for fname, w in FIXED_LOOKBACK.items():
            fn, _ = FACTOR_FUNCS[fname]
            try:
                s = fn(sub, w).dropna()
                row[fname] = round(float(s.iloc[-1]), 6) if len(s) else None
            except Exception:
                row[fname] = None
        stocks.append(row)

    return {"as_of": str(asof_date.date()), "stocks": stocks}


def _pick_key(factor: str, period_cfg: dict) -> str:
    if factor == "momentum":
        return f"mom{period_cfg['momentum']}"
    if factor == "trend":
        return f"trend{period_cfg['trend']}"
    return factor


def score_snapshot(snapshot: dict, weights: dict, period_cfg: dict, top_n: int = 12,
                   trend_filter: bool = True, cash_buffer: float = 0.05,
                   max_weight: float = 0.12) -> dict:
    """按因子权重给快照打分并选出 TopN，返回打分明细与目标权重。"""
    stocks = snapshot["stocks"]
    if not stocks:
        raise ValueError("快照为空")

    z_by_factor = {}
    for f, w in weights.items():
        if w <= 0:
            continue
        key = _pick_key(f, period_cfg)
        series = pd.Series({st["code"]: st.get(key) for st in stocks}, dtype="float64")
        valid = series.dropna()
        if len(valid) < 8:
            continue
        z = cross_section_z(valid.to_frame("v").T).iloc[0]
        z_by_factor[f] = z

    rows = []
    for st in stocks:
        code = st["code"]
        # 趋势过滤：价格低于 200 日线不入选
        if trend_filter and (st.get("ma200_dev") is None or st["ma200_dev"] < 0):
            continue
        total, contrib = 0.0, {}
        covered = 0
        for f, z in z_by_factor.items():
            v = z.get(code, np.nan)
            if v == v:
                c = weights[f] * float(v)
                contrib[f] = round(c, 4)
                total += c
                covered += 1
        if covered < max(2, len(z_by_factor) // 2):
            continue
        rows.append({"code": code, "score": total, "contrib": contrib, **{
            k: st.get(k) for k in ("close", "ma200_dev", "vol60")
        }})

    rows.sort(key=lambda r: -r["score"])
    selected = rows[:top_n]
    base = 1.0 - cash_buffer
    w_each = min(max_weight, base / len(selected)) if selected else 0.0
    for r in rows:
        r["weight"] = 0.0
    for r in selected:
        r["weight"] = round(w_each, 4)

    return {
        "as_of": snapshot["as_of"],
        "period": period_cfg,
        "weights": weights,
        "ranked": rows,
        "selected": selected,
        "invested": round(w_each * len(selected), 4),
        "n_candidates": len(rows),
    }


def print_result(res: dict, names: dict):
    p = res["period"]
    print(f"\n交易日 {res['as_of']} ｜ 推荐周期：{p['label']}（{p['days']} 个交易日）"
          f" ｜ 动量窗口 {p['momentum']} 日 ｜ 趋势窗口 {p['trend']} 日")
    print("因子权重：" + "，".join(
        f"{FACTOR_LABELS.get(k, k)} {v:.0%}" for k, v in sorted(res["weights"].items(), key=lambda x: -x[1]) if v > 0))
    print(f"候选 {res['n_candidates']} 只（已过 200 日线趋势过滤），推荐 {len(res['selected'])} 只，"
          f"合计仓位 {res['invested']:.1%}\n")
    header = f"{'#':>3} {'代码':<8}{'名称':<10}{'得分':>8}{'权重':>8}{'收盘':>9}{'距MA200':>9}{'年化波动':>9}"
    print(header)
    print("-" * len(header) + "-" * 6)
    for i, r in enumerate(res["selected"], 1):
        print(f"{i:>3} {r['code']:<8}{names.get(r['code'], ''):<10}{r['score']:>8.3f}{r['weight']:>8.1%}"
              f"{r['close']:>9.2f}{(r['ma200_dev'] or 0):>8.1%}{(r['vol60'] or 0):>8.1%}")
    print("\n各票因子贡献（得分 = Σ 权重×因子Z值）：")
    for i, r in enumerate(res["selected"][:8], 1):
        detail = "，".join(f"{FACTOR_LABELS.get(k, k)}{v:+.2f}" for k, v in
                          sorted(r["contrib"].items(), key=lambda x: -abs(x[1]))[:4])
        print(f"  {i}. {r['code']} {names.get(r['code'], ''):<6} {detail}")


def main():
    ap = argparse.ArgumentParser(description="下个周期推荐股票（因子+周期可调）")
    ap.add_argument("--period", default="月", help="周/双周/月/季/半年/年")
    ap.add_argument("--days", type=int, default=None, help="自定义周期（交易日数，3~250），优先于 --period")
    ap.add_argument("--style", default="", help="因子权重，如 momentum=0.4,low_vol=0.3,trend=0.3；可用 0 关闭某因子")
    ap.add_argument("--risk", default="平衡", help="保守/稳健/平衡/进取/激进（决定持仓数与单票上限）")
    ap.add_argument("--top", type=int, default=None, help="推荐股票数量")
    ap.add_argument("--asof", default=None, help="指定信号日 YYYY-MM-DD（默认最新交易日）")
    ap.add_argument("--no-trend-filter", action="store_true", help="关闭 200 日线趋势过滤")
    ap.add_argument("--refresh", action="store_true", help="强制刷新行情缓存")
    ap.add_argument("--json", default=None, help="推荐结果 JSON 输出路径")
    ap.add_argument("--dashboard", default=None, help="生成交互式选股看板 HTML")
    args = ap.parse_args()

    period_cfg = resolve_period(args.period, args.days)
    risk = RISK_LEVELS[args.risk]

    # 默认权重：默认风格按周期调整反转权重
    weights = dict(DEFAULT_STYLE)
    weights["reversal"] = period_cfg["reversal"]
    if args.style:
        override = {}
        for kv in args.style.split(","):
            k, v = kv.split("=")
            override[k.strip()] = float(v)
        weights.update(override)
    weights = normalize_style(weights)

    top_n = args.top or risk["max_positions"]

    codes = load_codes()
    print(f"加载 {len(codes)} 只标的行情{'（强制刷新）' if args.refresh else ''}...")
    data_map = dl.load_universe(codes, DATA_START, "2030-01-01", use_cache=not args.refresh)
    print(f"有效标的 {len(data_map)} 只")

    snapshot = build_snapshot(data_map)
    res = score_snapshot(snapshot, weights, period_cfg, top_n=top_n,
                         trend_filter=not args.no_trend_filter,
                         cash_buffer=risk["cash_buffer"], max_weight=risk["max_weight"])
    names = load_names()
    print_result(res, names)

    # 多周期对比：同一套因子下，不同周期各自推荐谁
    print("\n各周期推荐对比（Top5）：")
    for label, days in PERIOD_DAYS.items():
        pc = resolve_period(label)
        r = score_snapshot(snapshot, weights, pc, top_n=5,
                           trend_filter=not args.no_trend_filter,
                           cash_buffer=risk["cash_buffer"], max_weight=risk["max_weight"])
        picks = "、".join(f"{x['code']}{names.get(x['code'], '')}" for x in r["selected"])
        print(f"  {label:<3}({days:>3}日)  {picks}")

    os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
    payload = {
        "as_of": res["as_of"],
        "period": res["period"],
        "weights": res["weights"],
        "factor_labels": FACTOR_LABELS,
        "invested": res["invested"],
        "n_candidates": res["n_candidates"],
        "recommendations": [
            {"rank": i, "code": r["code"], "name": names.get(r["code"], ""),
             "score": round(r["score"], 4), "weight": r["weight"], "close": r["close"],
             "ma200_dev": r["ma200_dev"], "vol60": r["vol60"], "contrib": r["contrib"]}
            for i, r in enumerate(res["selected"], 1)
        ],
        "data_notes": {
            "kline_source": "腾讯/新浪日线（公开接口）",
            "universe": f"内置大市值样本池（{len(data_map)} 只有效标的，静态列表含幸存者偏差）",
            "method": "截面 MAD 去极值 + Z-score 加权合成，200 日线之上才入选",
        },
    }
    out_json = args.json or os.path.join(ROOT, "out", f"recommend_{period_cfg['label']}.json")
    with open(out_json, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
    print(f"\n推荐清单 JSON: {out_json}")

    if args.dashboard:
        from quantlab.dashboard import build_dashboard
        html = build_dashboard(snapshot, names, period_cfg, weights, top_n)
        with open(args.dashboard, "w", encoding="utf-8") as f:
            f.write(html)
        print(f"交互看板: {args.dashboard}")


if __name__ == "__main__":
    main()
