# -*- coding: utf-8 -*-
"""股價淨值比(PB)選股完整回測:五分位因子分析 + 絕對門檻對照 + 排雷實戰策略。

對應文章:
  https://finlab.finance/blog/price-book-ratio-stock-strategy

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

三層設計:
  A. 因子分析層:全上市櫃按 PB 分五分位,等權、每季再平衡
  B. 絕對門檻對照層:PB<0.5 / <0.8 / <1.0 全持有,每季再平衡
  C. 實戰策略層:低 PB 相對百分位(池內最低 20%)+ 淨值陷阱排雷
     (近四季經常稅後淨利>0、每股淨值不低於一年前)+ 股價>5 +
     流動性門檻 + 收盤價在季線之上,PB*股價 最小 20 檔等權,每季再平衡

成本口徑:
  - 策略使用 finlab sim() 台股預設成本(手續費 0.1425%、賣出證交稅 0.3%)
  - 0050 基準為 etl:adj_close 還原價純指數算術 buy-and-hold,不含成本
  - 績效統計對截斷到 DATA_END 的淨值以純算術公式計算,與 0050 基準同式

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

# finlab 會在需要資料時自動引導登入
finlab.login()

# 文章發佈時的資料截止日;拿掉這兩行即可回測到最新資料
DATA_END = "2026-06-09"
# 排雷策略需要一年每股淨值歷史與四季財報,文章統一從 2013-07 起算
STRAT_START = "2013-07-01"

# ---------- 載入資料 ----------
close = data.get("price:收盤價")
vol = data.get("price:成交股數")
pb = data.get("price_earning_ratio:股價淨值比")
ni = data.get("fundamental_features:經常稅後淨利").index_str_to_date()

close = close[close.index <= DATA_END]
vol = vol[vol.index <= DATA_END]
pb = pb[pb.index <= DATA_END]
ni = ni[ni.index <= DATA_END]

# 只保留正的 PB(每日資料,公開資訊觀測站口徑)
pb = pb.where(pb > 0)

# ---------- 統計口徑(與文章一致) ----------
# sim() 的 creturn 會延伸到執行當日;統計前先截斷到 DATA_END,
# 再對截斷後淨值用純算術公式計算,與 0050 基準同式
def clip_creturn(creturn):
    return creturn[creturn.index <= DATA_END]


def summarize(report):
    cr = clip_creturn(report.creturn)
    ret = cr.pct_change().dropna()
    total = float(cr.iloc[-1] / cr.iloc[0] - 1)
    years = (cr.index[-1] - cr.index[0]).days / 365.25
    return {
        "cagr": ((1 + total) ** (1 / years) - 1) * 100,
        "daily_sharpe": float(ret.mean() / ret.std() * 252 ** 0.5),
        "daily_sortino": float(ret.mean() / ret[ret < 0].std() * 252 ** 0.5),
        "max_drawdown": float((cr / cr.cummax() - 1).min()) * 100,
    }


# ---------- A. 五分位因子分析 ----------
universe = close.notna() & pb.notna()
pb_rank = pb.where(universe).rank(axis=1, pct=True)

# 最低 20% 分位組(其他分位依此類推)
q1_position = pb_rank <= 0.2
q1_report = sim(q1_position, resample="Q", upload=False)
print("最低 PB 五分位 CAGR(%):", round(summarize(q1_report)["cagr"], 2))

# ---------- B. 絕對門檻對照 ----------
pb_below_08 = pb.where(universe) < 0.8
threshold_report = sim(pb_below_08, resample="Q", upload=False)
holdings_count = pb_below_08.sum(axis=1)
print("PB<0.8 持股檔數區間:", holdings_count.min(), "~", holdings_count.max())

# ---------- C. 實戰策略:低 PB 相對百分位 + 淨值陷阱排雷 ----------
# 股票池:60 日均量 > 50 萬股,股價 > 5 元
pool = (vol.rolling(60).mean() > 500_000) & (close > 5)

# 趨勢濾網:收盤價在季線(60 日均線)之上
ma60 = close.rolling(60).mean()
above_ma = close > ma60

# 排雷一:近四季經常稅後淨利 > 0(獲利能力)
ni4 = ni.rolling(4).sum()
profitable = ni4.reindex(close.index, method="ffill") > 0

# 排雷二:每股淨值不低於一年前(淨值未持續燒掉)
bps = close / pb
bps_ok = bps >= bps.shift(245)

# 池內 PB 相對百分位:取最低 20% 分位(不用絕對門檻)
pb_rank_pool = pb.where(pool).rank(axis=1, pct=True)

candidates = (pb_rank_pool <= 0.2) & profitable & bps_ok & above_ma

# 從候選中選 PB * 股價 最小的 20 檔,等權持有,每季再平衡
position = (pb * close).where(candidates).is_smallest(20)
position = position[position.index >= STRAT_START]
report = sim(position, resample="Q", upload=False)

print("實戰策略統計:")
stats = summarize(report)
for key in ["cagr", "daily_sharpe", "daily_sortino", "max_drawdown"]:
    print(f"  {key}: {stats[key]:.4f}")

report.display()
