# -*- coding: utf-8 -*-
"""KD 在台股的正確用法:把 KD 拆成兩個橫截面零件的純技術策略(台股)。

核心洞見:整個 KD 指標當個股買賣訊號(黃金交叉 / 超買超賣 / 橫截面排名 / 超賣均值回歸)
雜訊太大,在台股沒有可重現的選股 edge。但同一個 K−D 換兩種橫截面用法就活了過來:
  1) K−D 的 5 日斜率,當選股因子——在 120 日動能框出的強勢族群裡,挑相對落後、正在補漲的股票;
  2) 全市場 K−D>0 的個股佔比(廣度),當大盤風控開關——廣度與 0050 年線雙條件轉弱時部分減碼。
兩個零件缺一不可:拆掉 KD 廣度只留趨勢濾網,樣本外 Sharpe 1.32→0.86、全期 MDD -25%→-57%;
拆掉 KD 斜率因子(純動能選股),研究段 CAGR 35.3%→22.5% 且不過關。

研究段 2015-2021 年化 35.3% / 月 Sharpe 1.21;樣本外 2022-2026 年化 25.2% / 1.32;
全期 2015-2026 年化 31.1% / 1.20、最大回撤 -25.4%,三段皆勝含息 0050(全期 21.0% / 1.05 / -34.0%)。

資料來源:finlab 套件(price / etl:adj_close)。finlab 會在需要資料時自動引導登入。
回測區間 2015-01 ~ 2026-06。過去績效不代表未來,不構成投資建議。
"""
import numpy as np
import pandas as pd
import finlab
from finlab import data
from finlab.backtest import sim

finlab.login()  # 依套件指示完成登入即可

# 1) 載入價量資料
close = data.get("price:收盤價")
amount = data.get("price:成交金額")

# 2) 可交易宇宙:成交額前 300 大、收盤 > 10 元,排除 ETF(00 開頭)與權證 / KY(非 4 碼純數字)
liquidity = amount.rolling(60).mean()
is_common = pd.Series({sid: (str(sid)[:4].isdigit() and not str(sid).startswith("00"))
                       for sid in close.columns})
universe = liquidity.is_largest(300) & (close > 10) & is_common

# 3) 120 日動能取池內前 80 檔當動能池
ret120 = close / close.shift(120) - 1
mom_pool = ret120.where(universe).rank(axis=1, ascending=False) <= 80

# 4) KD 的 5 日斜率:量「K−D 轉強的速度」,當選股因子
slowk, slowd = data.indicator("STOCH", fastk_period=6, slowk_period=3, slowd_period=3)
kd_diff = slowk - slowd
kd_slope = kd_diff - kd_diff.shift(5)

# 5) 綜合分(越小越優先)= 0.6 動能分位 + 0.4 KD 斜率分位,挑強勢族群裡的補漲股,取最低 10 檔等權
mom_score = ret120.where(mom_pool).rank(axis=1, ascending=False, pct=True)
slope_score = kd_slope.where(mom_pool).rank(axis=1, ascending=False, pct=True)
combo = 0.6 * mom_score + 0.4 * slope_score
basket = combo.rank(axis=1, ascending=True) <= 10

# 6) KD 廣度:宇宙內 K−D>0 的個股比例,反映大盤短期內部動能
breadth = (kd_diff > 0).where(universe).sum(axis=1) / universe.sum(axis=1)

# 7) 雙條件 risk-on:「廣度 > 0.5」且「0050 站上 200 日均線」
adj = data.get("etl:adj_close")
tw0050 = adj["0050"]
trend_up = tw0050 > tw0050.rolling(200).mean()
risk_on = (breadth > 0.5) & trend_up.reindex(breadth.index).ffill().fillna(False)

# 8) 部分減碼:risk-on 滿倉等權;risk-off 時整籃部位縮到 35%(off_frac=0.35,非全清空、不加槓桿)
weights = basket.astype(float)
weights = weights.div(weights.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)
scale = pd.Series(np.where(risk_on.reindex(weights.index).fillna(False).values, 1.0, 0.35),
                  index=weights.index)
position = weights.mul(scale, axis=0)

# 9) 回測(季再平衡,台股預設成本:手續費 + 賣出證交稅 0.3%)
report = sim(position, resample="Q", fee_ratio=1.425/1000*0.3, upload=False)
print(report.get_stats())
