# -*- coding: utf-8 -*-
"""杜邦分析選股研究:三因子五分位回測 + 營運驅動 vs 槓桿驅動 ROE 策略。

對應文章:
  https://finlab.finance/blog/dupont-analysis-stock-picking-intro

執行(pip install finlab 後):
  python strategy.py

共同設定:
  - 主策略回測區間:2018-01-01 ~ 2026-06-09(DATA_END 釘日,確保數字可重現)
  - 五分位與原文策略檢驗:財報資料最早可得日(2013-Q1)起
  - 股票池:上市櫃普通股,排除金融股(權益乘數結構性偏高,會污染槓桿分位)
  - 交易成本:策略使用 finlab sim() 台股預設值(手續費 0.1425% 與賣出證交稅 0.3%)
  - 基準:0050 含息(etl:adj_close 還原價)買進持有,純指數算術,不含交易成本

投資警語:本程式僅供量化研究與教學用途,過去績效不代表未來表現,
不構成任何投資建議;實際交易前請自行評估風險、滑價與交易容量。
"""
import numpy as np
import pandas as pd
from finlab import data
from finlab.backtest import sim

START = "2018-01-01"
END = "2026-06-09"


def cap(df):
    """資料一律截斷到 END(釘日):未來重跑時,END 之後才公告的財報或新增的價格
    不會混進回測,確保文中數字可重現。"""
    return df[df.index <= END] if hasattr(df, "index") else df


def clip_creturn(creturn):
    """sim() 的 creturn 可能延伸到執行當日,統計前必須截斷到 END,
    與 0050 基準同一窗口、同一套純算術公式。"""
    return creturn[creturn.index <= END].dropna()


def arithmetic_stats(creturn):
    """純算術績效統計:CAGR、日夏普、最大回撤,與 0050 基準同式。"""
    cr = clip_creturn(creturn)
    total = cr.iloc[-1] / cr.iloc[0] - 1
    years = (cr.index[-1] - cr.index[0]).days / 365.25
    daily_returns = cr.pct_change().dropna()
    return {
        "cagr": round(((1 + total) ** (1 / years) - 1) * 100, 2),
        "daily_sharpe": round(float(daily_returns.mean() / daily_returns.std() * (252 ** 0.5)), 2),
        "max_drawdown": round(float((cr / cr.cummax() - 1).min()) * 100, 2),
    }


# ---------- 0050 含息基準(純指數算術,不經 sim) ----------
adj = data.get("etl:adj_close")
bench = adj["0050"].dropna()
bench_2018 = bench[(bench.index >= START) & (bench.index <= END)]
bench_total = bench_2018.iloc[-1] / bench_2018.iloc[0] - 1
bench_years = (bench_2018.index[-1] - bench_2018.index[0]).days / 365.25
print("0050 含息 CAGR:", round(((1 + bench_total) ** (1 / bench_years) - 1) * 100, 2), "%")

# ---------- 股票池:上市櫃普通股,排除金融股 ----------
data.set_universe(market="TSE_OTC", exclude_category="金融")

close = cap(data.get("price:收盤價"))
vol = cap(data.get("price:成交股數"))
pb = cap(data.get("price_earning_ratio:股價淨值比"))

# 杜邦三因子(季頻,index 為 2026-Q1 這類字串)
npm_q = data.get("fundamental_features:稅後淨利率")     # 淨利率(%)
roe_q = data.get("fundamental_features:ROE稅後")         # 單季 ROE(%)
rev_q = data.get("financial_statement:營業收入淨額")     # 單季營收
assets_q = data.get("financial_statement:資產總額")
equity_q = data.get("financial_statement:股東權益總額")

tat_q = rev_q / assets_q        # 總資產週轉率(單季)
em_q = assets_q / equity_q      # 權益乘數

# ---------- 公告日對齊(index_str_to_date 轉成財報公告截止日,避免前視) ----------
# 對齊公告日後一律 cap 到 END:END 之後公告的新一季財報(例如 2026-Q2 在 8/14 公告)
# 不會在重跑時長出新的換股日
npm = cap(npm_q.index_str_to_date())
roe = cap(roe_q.index_str_to_date())
tat = cap(tat_q.index_str_to_date())
em = cap(em_q.index_str_to_date())

# 與去年同季比較的變化量(diff(4) = 對齊去年同季,避免季節性干擾)
dnpm = cap(npm_q.diff(4).index_str_to_date())
dtat = cap(tat_q.diff(4).index_str_to_date())
dem = cap(em_q.diff(4).index_str_to_date())


# ---------- 三因子五分位回測(全樣本,財報日換股) ----------
def quintile_backtest(factor, name):
    rank = factor.replace([np.inf, -np.inf], np.nan).rank(axis=1, pct=True)
    for i in range(5):
        lower = i * 0.2
        upper = (i + 1) * 0.2
        position = (rank > lower) & (rank <= upper)
        report = sim(position, upload=False)
        stats = arithmetic_stats(report.creturn)
        print(name, f"Q{i+1}",
              "CAGR:", stats["cagr"], "%",
              "日夏普:", stats["daily_sharpe"],
              "MDD:", stats["max_drawdown"], "%")


quintile_backtest(npm, "淨利率")
quintile_backtest(tat, "總資產週轉率")
quintile_backtest(em, "權益乘數")

# ---------- 主策略:營運驅動 ROE vs 槓桿驅動 ROE ----------
# 共同條件:流動性、單季 ROE > 5%、股價淨值比 < 3
liquidity = (vol.rolling(60).mean() > 500_000).reindex(roe.index, method="ffill")
pb_at_report = pb.reindex(roe.index, method="ffill")
high_roe = (roe > 5) & liquidity & (pb_at_report < 3)

# 營運驅動:ROE 的改善來自淨利率或週轉率,且槓桿沒有擴張
operating_driven = high_roe & ((dnpm > 0) | (dtat > 0)) & (dem <= 0)

# 槓桿驅動:權益乘數較去年同季擴張
leverage_driven = high_roe & (dem > 0)


def run_strategy(condition, name, topn=30):
    # 條件內依 ROE 取前 topn 檔,等權持有
    selected = roe.where(condition).rank(axis=1, ascending=False) <= topn
    # 轉日頻並從 2018 年起算,窗口與 0050 基準一致
    position = selected.reindex(close.index, method="ffill").fillna(False)
    position = position[(position.index >= START) & (position.index <= END)]
    report = sim(position, upload=False)
    stats = arithmetic_stats(report.creturn)
    print(name,
          "CAGR:", stats["cagr"], "%",
          "日夏普:", stats["daily_sharpe"],
          "MDD:", stats["max_drawdown"], "%")
    return report


report_operating = run_strategy(operating_driven, "營運驅動 ROE")
report_leverage = run_strategy(leverage_driven, "槓桿驅動 ROE")

# ---------- 2020 年原文整合策略重跑:TAT > 0.3 + ROE > 5 + PB < 3 ----------
original_condition = (tat > 0.3) & (roe > 5) & (pb_at_report < 3)
report_original = sim(original_condition, upload=False)
stats = arithmetic_stats(report_original.creturn)
print("原文整合策略(2013-2026 全樣本)",
      "CAGR:", stats["cagr"], "%",
      "日夏普:", stats["daily_sharpe"],
      "MDD:", stats["max_drawdown"], "%")
