# -*- coding: utf-8 -*-
"""布林雙核心純技術選股策略 × 中軌廣度風控(台股)。

核心洞見:布林通道單獨拿來選股(觸下軌買)在台股近乎無效;但把它拆成兩個有用的
零件——「擠壓狀態下的 %b 位置」當選股因子、「全市場站上中軌的廣度」當大盤風控——
再疊上 70 日動能與低波加權,就成了一套完全不含基本面、月頻換股的純技術策略。

資料來源: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) 布林通道(20 日、±2 標準差)與衍生量
upper, middle, lower = data.indicator("BBANDS", timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
percent_b = (close - lower) / (upper - lower)        # %b:價格在通道中的相對位置
bandwidth = (upper - lower) / middle                 # 帶寬

# 4) 三個技術因子,各自轉成橫截面百分位(0~1,越高越好)
#    ① 動能:70 日報酬
mom_rank = (close / close.shift(70) - 1).where(universe).rank(axis=1, pct=True)

#    ② 布林擠壓下的 %b:只在帶寬收到近 120 日中位以下(擠壓)時才計分
squeeze = bandwidth < bandwidth.rolling(120, min_periods=60).median()
squeeze_pb_rank = percent_b.where(universe & squeeze).rank(axis=1, pct=True)

#    ③ 低波:NATR = ATR20 / 收盤,取負後排名(越低波越高分),權重 0.35
atr = data.indicator("ATR", timeperiod=20)
natr = atr / close
lowvol_rank = (-natr).where(universe).rank(axis=1, pct=True)

# 5) 綜合分數,取最高 10 檔
score = mom_rank.add(squeeze_pb_rank, fill_value=0) + 0.35 * lowvol_rank
basket = score.where(universe).rank(axis=1, ascending=False) <= 10

# 6) 反波動加權(1 / NATR),逐日歸一
inv_vol = (1.0 / natr).where(basket)
weights = inv_vol.div(inv_vol.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)

# 7) 大盤風控:全市場站上各自布林中軌的廣度 → EMA10 → 雙門檻 hysteresis
above_mid = (close > middle).where(universe)
breadth = (above_mid.sum(axis=1) / universe.sum(axis=1)).ewm(span=10).mean()

state = np.empty(len(breadth), dtype=bool)
risk_on = False
for i, v in enumerate(breadth.values):
    if not risk_on and v > 0.42:        # 廣度 > 0.42 轉 risk-on
        risk_on = True
    elif risk_on and v < 0.27:          # 廣度 < 0.27 轉 risk-off
        risk_on = False
    state[i] = risk_on
risk_on = pd.Series(state, index=breadth.index)

# 8) 配置:risk-on 滿倉持選股籃;risk-off 選股清零,改持 0050 佔總曝險 0.5(不空手)
mask = pd.DataFrame(np.repeat(risk_on.reindex(weights.index).ffill().fillna(False).values[:, None],
                              weights.shape[1], axis=1), index=weights.index, columns=weights.columns)
weights = weights.where(mask, 0.0)
weights["0050"] = np.where(risk_on.reindex(weights.index).ffill().fillna(False).values, 0.0, 0.5)

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