{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "46cfb2e5",
   "metadata": {},
   "source": [
    "# Class 2｜股票：預測一週後的趨勢（台積電 2330）\n",
    "\n",
    "**任務**：今天收盤後，預測「今天收盤價」相對「7 天後的 15 日均線」的乖離率（%），也就是未來一週的趨勢（P130、P131）。\n",
    "\n",
    "**開啟後先按「執行階段 → 全部執行」（Ctrl＋F9）**，跑完約 10 秒，再直接往下捲到 **B4 🏆 挑戰控制台** 開始調整。\n",
    "B1–B3 的資料處理已經填好答案（老師上課示範用），不用自己寫；只改標著 🎛️ 的地方。\n",
    "\n",
    "| 投影片 | Colab | 做什麼 |\n",
    "|---|---|---|\n",
    "| P132–P140（P150 老師示範） | B1–B3 | 資料處理（已填好，直接跑）：清洗、均線、乖離率、shift 建答案、基準、時間切分、標準化 |\n",
    "| P147 | B2 | 🎛️ 設定題目：均線天數、預測幾天後 |\n",
    "| P144–P148 | B4 | 🏆 挑戰控制台：勾選特徵、調模型，把誤差比壓到目標線以下、衝排行榜 |\n",
    "| P155 | B7 | 最後測試（2025，只看一次）、匯出 Class 3 交接檔 |\n",
    "\n",
    "資料是 TWSE 2330 在 2023–2025 年的未還原價格（新臺幣）與成交股數，已內附。2023 訓練、2024 驗證、2025 最後測試。\n",
    "神經網路是用 NumPy 自己寫的，和鐵達尼號同一套，只是輸出層不接 sigmoid，直接輸出數字（回歸）。這不是投資建議，也不保證獲利。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4ece8f9a",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 準備工具與內附資料（執行一次，不用改；想看怎麼寫可以展開）\n",
    "\"\"\"股票實作工具。神經網路用 NumPy 自己寫（前向、反向、更新），輸出層不接 sigmoid，直接輸出數字（回歸）。\n",
    "時間切分：2023 訓練、2024 驗證、2025 最後測試；標準化只從訓練集學。\"\"\"\n",
    "import io, gzip, base64, copy, time, json, hashlib, zipfile, warnings\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib\n",
    "import matplotlib.pyplot as plt\n",
    "from IPython.display import display, HTML\n",
    "from sklearn import __version__ as sklearn_version\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.linear_model import Ridge\n",
    "from sklearn.svm import SVR\n",
    "warnings.filterwarnings('ignore')\n",
    "SEED = 42\n",
    "NAVY, ORANGE, GRAY, GREEN = '#073F81', '#ED802C', '#8796A5', '#2E8B57'\n",
    "plt.rcParams.update({'font.size': 12, 'axes.labelsize': 13, 'lines.linewidth': 2.2,\n",
    "                     'figure.dpi': 110, 'axes.spines.top': False, 'axes.spines.right': False})\n",
    "\n",
    "class Stop(Exception):\n",
    "    \"\"\"停下這一格，只顯示一句話。\"\"\"\n",
    "    def _render_traceback_(self): return [str(self)]\n",
    "\n",
    "def compact_table(df, mark=None):\n",
    "    df = df.copy()\n",
    "    if mark is not None: df.insert(0, '', np.where(df['回合'] == mark, '👉', ''))\n",
    "    display(HTML('<div style=\"max-height:320px;overflow:auto\">' + df.to_html(index=False, na_rep='', float_format=lambda v: f'{v:.3g}' if abs(v) < .01 else f'{v:.3f}') + '</div>'))\n",
    "\n",
    "def stock_features(raw, ma=15, horizon=7):\n",
    "    d=raw.copy();d['Date']=pd.to_datetime(d.Date,errors='coerce')\n",
    "    for c in ['Open','High','Low','Close','Volume']:\n",
    "        d[c]=pd.to_numeric(d[c].astype(str).str.replace(',','',regex=False),errors='coerce')\n",
    "    if d.Date.isna().any() or d.Date.duplicated().any(): raise ValueError('日期缺漏或重複，先查來源。')\n",
    "    d=d.sort_values('Date').reset_index(drop=True)\n",
    "    cols=['Open','High','Low','Close','Volume']\n",
    "    if d[cols].isna().any().any() or (d[cols]<=0).any().any(): raise ValueError('價量缺值或非正數，請查證，不可偷用未來值補。')\n",
    "    assert ((d.High>=d[['Open','Close','Low']].max(axis=1)) & (d.Low<=d[['Open','Close','High']].min(axis=1))).all()\n",
    "    for w in [5,10,20,60]:d[f'MA{w}']=d.Close.rolling(w,min_periods=w).mean()\n",
    "    d['BIAS20']=(d.Close/d.MA20-1)*100\n",
    "    d['MA_T']=d.Close.rolling(ma,min_periods=ma).mean();d['BIAS_T']=(d.Close/d.MA_T-1)*100\n",
    "    d['return1']=d.Close.pct_change()*100\n",
    "    d['return5']=d.Close.pct_change(5)*100\n",
    "    d['vol5']=d['return1'].rolling(5).std()\n",
    "    d['range_pct']=(d.High-d.Low)/d.Close*100\n",
    "    d['oc_pct']=(d.Close/d.Open-1)*100\n",
    "    d['gap_pct']=(d.Open/d.Close.shift(1)-1)*100\n",
    "    d['volume_ratio']=d.Volume/d.Volume.rolling(20).mean()\n",
    "    d['ma5_20']=d.MA5/d.MA20;d['ma10_20']=d.MA10/d.MA20;d['ma20_60']=d.MA20/d.MA60\n",
    "    lo=d.Low.rolling(9).min();hi=d.High.rolling(9).max()\n",
    "    rsv=100*(d.Close-lo)/(hi-lo).replace(0,np.nan)\n",
    "    # Explicit Taiwan-style recursive smoothing, initial K=D=50; zero-range uses neutral 50.\n",
    "    k=50.;dd=50.;ks=[];ds=[]\n",
    "    for i,v in enumerate(rsv):\n",
    "        if i<8:ks.append(np.nan);ds.append(np.nan);continue\n",
    "        v=50. if pd.isna(v) else v\n",
    "        k=2/3*k+v/3;dd=2/3*dd+k/3;ks.append(k);ds.append(dd)\n",
    "    d['K9']=ks;d['D9']=ds\n",
    "    # RSI14 simple rolling means (not Wilder RSI); convention is documented for reproducibility.\n",
    "    delta=d.Close.diff();gain=delta.clip(lower=0).rolling(14).mean();loss=(-delta.clip(upper=0)).rolling(14).mean()\n",
    "    d['RSI14']=100-100/(1+gain/loss.replace(0,np.nan))\n",
    "    d.loc[(loss==0)&(gain>0),'RSI14']=100;d.loc[(loss==0)&(gain==0),'RSI14']=50\n",
    "    # target: today's close vs the ma-day moving average `horizon` trading days later (percent)\n",
    "    d['target']=(d.Close/d.MA_T.shift(-horizon)-1)*100;d['target_date']=d.Date.shift(-horizon)\n",
    "    # 基準①均線不動：今天的乖離率。基準②價格不動（隨機漫步）：未來 horizon 天的收盤價都當成今天，代進 horizon 天後的均線\n",
    "    d['baseline_ma']=d.BIAS_T\n",
    "    k=ma-horizon; ma_rw=(d.Close.rolling(k).sum()+horizon*d.Close)/ma if k>0 else d.Close\n",
    "    d['baseline']=(d.Close/ma_rw-1)*100\n",
    "    d['next_open']=d.Open.shift(-1);d['next_close']=d.Close.shift(-1)\n",
    "    return d\n",
    "\n",
    "\n",
    "# ---------- 特徵、答案、切分 ----------\n",
    "FEATURES = ['BIAS_T', 'BIAS20', 'return1', 'return5', 'vol5', 'range_pct', 'oc_pct', 'gap_pct', 'volume_ratio',\n",
    "            'ma5_20', 'ma10_20', 'ma20_60', 'K9', 'D9', 'RSI14']\n",
    "GROUPS = {'A': ['BIAS_T', 'BIAS20', 'return1'],\n",
    "          'B': ['BIAS_T', 'BIAS20', 'return1', 'range_pct', 'oc_pct', 'gap_pct', 'volume_ratio'],\n",
    "          'C': ['BIAS_T', 'BIAS20', 'return1', 'range_pct', 'oc_pct', 'gap_pct', 'volume_ratio', 'ma5_20', 'ma10_20', 'ma20_60'],\n",
    "          'D': ['BIAS_T', 'BIAS20', 'return1', 'range_pct', 'oc_pct', 'gap_pct', 'volume_ratio', 'ma5_20', 'ma10_20', 'ma20_60', 'K9', 'D9', 'RSI14']}\n",
    "PANEL_LABELS = [('BIAS_T', 'BIAS_15'), ('BIAS20', 'BIAS_20'), ('return1', 'Return_1d'), ('return5', 'Return_5d'), ('vol5', 'Volatility_5d'), ('range_pct', 'Range_day'), ('oc_pct', 'Intraday_change'), ('gap_pct', 'Gap_open'), ('volume_ratio', 'Volume_ratio'), ('ma5_20', 'MA5_over_MA20'), ('ma10_20', 'MA10_over_MA20'), ('ma20_60', 'MA20_over_MA60'), ('K9', 'KD_K'), ('D9', 'KD_D'), ('RSI14', 'RSI_14')]   # 控制台勾選框名稱 → 特徵欄位\n",
    "PAST_COLS = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'MA5', 'MA10', 'MA20', 'MA60', 'MA_T'] + FEATURES   # 給 my_features 用：全是「今天收盤就知道」的欄位\n",
    "OFFICIAL = (15, 7)\n",
    "s_runs, s_state, tries = [], {'locked': False}, []\n",
    "\n",
    "def s_setup(ma=15, horizon=7, show=True):\n",
    "    \"\"\"建立答案、固定時間切分、算基準。改 MA_DAYS／HORIZON 後要重跑這一格。\"\"\"\n",
    "    global stock_data, s_train, s_valid, s_test, BASE, MA_DAYS, HORIZON\n",
    "    MA_DAYS, HORIZON = ma, horizon\n",
    "    stock_data = stock_features(stock_raw, ma=ma, horizon=horizon)\n",
    "    usable = stock_data.dropna(subset=FEATURES + ['target', 'target_date']).copy()        # 所有特徵都算得出來的日子，大家用同一批\n",
    "    s_train = usable[(usable.Date < '2024-01-01') & (usable.target_date < '2024-01-01')].copy()\n",
    "    s_valid = usable[(usable.Date >= '2024-01-01') & (usable.Date < '2025-01-01') & (usable.target_date < '2025-01-01')].copy()\n",
    "    s_test = usable[usable.Date >= '2025-01-01'].copy()\n",
    "    BASE = s_score(s_valid.target, s_valid.baseline); BASE['train_mae'] = s_score(s_train.target, s_train.baseline)['mae']\n",
    "    BASE_MA = s_score(s_valid.target, s_valid.baseline_ma); globals()['BASE_MA'] = BASE_MA\n",
    "    if show:\n",
    "        print(f'📋 題目設定（這一格只建立資料和答案，還沒有訓練任何模型）')\n",
    "        print(f'答案：今天收盤價相對 {horizon} 天後的 {ma} 日均線（%）｜訓練 {len(s_train)} 天、驗證 {len(s_valid)} 天、測試 {len(s_test)} 天')\n",
    "        print(f'（天數主要由「所有指標都算得出來」決定：MA60 要先暖身 60 天，所以均線天數改來改去，天數幾乎不變；預測天數越長，最後沒有答案的日子越多。）')\n",
    "        print(f'B4 的 BIAS_15、MA15_slope 會跟著換成 {ma} 日均線；下面所有格子都用這個題目，直到你再改這一格。')\n",
    "        print(f\"基準①均線不動（今天的乖離率，P139）：驗證 MAE={BASE_MA['mae']:.2f} 個百分點｜方向正確率={BASE_MA['dir']:.1%}　← 太弱：連每天猜 0 都不如\")\n",
    "        print(f\"基準②價格不動（未來 {horizon} 天收盤價都當成今天，隨機漫步）：驗證 MAE={BASE['mae']:.2f}｜方向正確率={BASE['dir']:.1%}　← 真正要贏的對手\")\n",
    "        k = ma - horizon\n",
    "        if k > 0: print(f\"   算法：{horizon} 天後的 {ma} 日均線裡，有 {k} 天今天已經知道，剩下 {horizon} 天都填今天的收盤價：(最近 {k} 天收盤價總和 ＋ {horizon} × 今天收盤價) ÷ {ma}\")\n",
    "        s_direction(s_valid.target, s_valid.baseline, '基準②價格不動')\n",
    "        print(f\"（基準②在 2023 訓練期的 MAE 只有 {BASE['train_mae']:.2f}：2024 年波動比較大，所以比較時看「誤差比」＝模型 MAE ÷ 基準② MAE，P80。誤差比 < 1 才算贏過隨機漫步。）\")\n",
    "        if (ma, horizon) != OFFICIAL: print(f'ℹ️ 這不是正式題目（15 日均線、7 天後）：可以探索，但不計牌。')\n",
    "        fig, ax = plt.subplots(figsize=(12, 3.8), layout='constrained')\n",
    "        ax.plot(s_valid.target_date, s_valid.target, color=GRAY, label='Actual')\n",
    "        ax.plot(s_valid.target_date, s_valid.baseline_ma, color=ORANGE, lw=1.2, alpha=.8, label='Baseline 1: MA unchanged (today\\'s bias)')\n",
    "        ax.plot(s_valid.target_date, s_valid.baseline, color=GREEN, lw=1.6, label='Baseline 2: price unchanged (random walk)')\n",
    "        ax.axhline(0, color='k', lw=.8); ax.set(ylabel='Close vs future MA (%)', title='Validation 2024: two baselines (beat baseline 2)'); ax.legend(fontsize=9); ax.grid(alpha=.2); plt.show()\n",
    "\n",
    "def s_hits(ax, dates, y, pred):\n",
    "    \"\"\"在圖上標出每一天方向猜對（綠）或猜錯（紅）。\"\"\"\n",
    "    y, pred = np.asarray(y, float), np.asarray(pred, float); ok = (pred < 0) == (y < 0)\n",
    "    ax.scatter(np.asarray(dates)[ok], y[ok], s=10, color=GREEN, zorder=3, label='Direction right')\n",
    "    ax.scatter(np.asarray(dates)[~ok], y[~ok], s=16, color='#C62828', marker='x', zorder=3, label='Direction wrong')\n",
    "\n",
    "def s_direction(y, pred, name='模型'):\n",
    "    \"\"\"方向對照表：趨勢往上＝答案 < 0（7 天後的 15 日均線比今天收盤價高）。\"\"\"\n",
    "    y, pred = np.asarray(y, float), np.asarray(pred, float)\n",
    "    up_p, up_y = pred < 0, y < 0\n",
    "    a, b = int(np.sum(up_p & up_y)), int(np.sum(up_p & ~up_y)); c, d = int(np.sum(~up_p & up_y)), int(np.sum(~up_p & ~up_y)); n = len(y)\n",
    "    print(f'📈 {name}的方向（趨勢往上＝7 天後的 15 日均線比今天收盤價高，也就是答案 < 0）')\n",
    "    print(f'            實際往上   實際往下')\n",
    "    print(f'   預測往上   {a:5d}     {b:5d}')\n",
    "    print(f'   預測往下   {c:5d}     {d:5d}')\n",
    "    print(f'   方向正確率 = (猜對往上 + 猜對往下) ÷ 天數 = ({a} + {d}) ÷ {n} = {(a + d) / n:.1%}'\n",
    "          f'｜說往上時猜中 {a / max(a + b, 1):.0%}｜說往下時猜中 {d / max(c + d, 1):.0%}')\n",
    "    return (a + d) / n\n",
    "\n",
    "def s_score(y, pred):\n",
    "    y, pred = np.asarray(y, float), np.asarray(pred, float)\n",
    "    return dict(mae=float(np.mean(np.abs(pred - y))), rmse=float(np.sqrt(np.mean((pred - y) ** 2))),\n",
    "                dir=float(np.mean((pred < 0) == (y < 0))), corr=float(np.corrcoef(pred, y)[0, 1]) if np.std(pred) > 0 else 0.)\n",
    "\n",
    "# ---------- NumPy 神經網路（回歸：輸出層直接輸出數字） ----------\n",
    "ACT = {'relu':    (lambda a: np.maximum(0, a), lambda a, h: (a > 0).astype(float)),\n",
    "       'sigmoid': (lambda a: 1 / (1 + np.exp(-np.clip(a, -60, 60))), lambda a, h: h * (1 - h)),\n",
    "       'tanh':    (np.tanh, lambda a, h: 1 - h ** 2),\n",
    "       'none':    (lambda a: a, lambda a, h: np.ones_like(a))}\n",
    "\n",
    "def loss_grad(kind, y, yhat):\n",
    "    \"\"\"損失對輸出 ŷ 的梯度：MSE → 2(ŷ − y)；MAE → sign(ŷ − y)。\"\"\"\n",
    "    return 2 * (yhat - y) if kind == 'MSE' else np.sign(yhat - y)\n",
    "\n",
    "class TinyNet:\n",
    "    def __init__(self, d, hidden, activation, seed):\n",
    "        self.rng = np.random.default_rng(seed); self.activation = activation\n",
    "        sizes = [d] + list(hidden) + [1]; self.W, self.b = [], []\n",
    "        for i, (m, n) in enumerate(zip(sizes[:-1], sizes[1:])):\n",
    "            gain = 2 if (activation == 'relu' and i < len(hidden)) else 1\n",
    "            self.W.append(self.rng.normal(0, np.sqrt(gain / m), (m, n))); self.b.append(np.zeros(n))\n",
    "    def forward(self, X, dropout=0., train=False):\n",
    "        f, _ = ACT[self.activation]; cache = []; h = X\n",
    "        for W, b in zip(self.W[:-1], self.b[:-1]):\n",
    "            a = h @ W + b; act = f(a); mask = None\n",
    "            if train and dropout > 0: mask = (self.rng.random(act.shape) >= dropout) / (1 - dropout)\n",
    "            cache.append((h, a, act, mask)); h = act if mask is None else act * mask\n",
    "        return (h @ self.W[-1] + self.b[-1])[:, 0], cache + [h]\n",
    "    def backward(self, cache, dy):\n",
    "        _, df = ACT[self.activation]; g = dy[:, None] / len(dy)\n",
    "        gW = [None] * len(self.W); gb = [None] * len(self.b)\n",
    "        gW[-1] = cache[-1].T @ g; gb[-1] = g.sum(0); g = g @ self.W[-1].T\n",
    "        for i in range(len(self.W) - 2, -1, -1):\n",
    "            h, a, act, mask = cache[i]\n",
    "            if mask is not None: g = g * mask\n",
    "            g = g * df(a, act); gW[i] = h.T @ g; gb[i] = g.sum(0); g = g @ self.W[i].T\n",
    "        return gW, gb\n",
    "\n",
    "class Optimizer:\n",
    "    def __init__(self, kind, params, beta=.9):\n",
    "        self.kind, self.beta, self.t = kind, beta, 0\n",
    "        self.v = [np.zeros_like(p) for p in params]; self.s = [np.zeros_like(p) for p in params]\n",
    "    def step(self, params, grads, lr):\n",
    "        self.t += 1\n",
    "        for i, (p, g) in enumerate(zip(params, grads)):\n",
    "            if self.kind == 'SGD': p -= lr * g\n",
    "            elif self.kind == 'Momentum':\n",
    "                self.v[i] = self.beta * self.v[i] + g; p -= lr * self.v[i]\n",
    "            else:\n",
    "                self.v[i] = .9 * self.v[i] + .1 * g; self.s[i] = .999 * self.s[i] + .001 * g ** 2\n",
    "                p -= lr * (self.v[i] / (1 - .9 ** self.t)) / (np.sqrt(self.s[i] / (1 - .999 ** self.t)) + 1e-8)\n",
    "\n",
    "class NetRegressor:\n",
    "    \"\"\"包成 predict(X)：內部把答案標準化再訓練，預測時換回百分點。\"\"\"\n",
    "    def __init__(self, net, mu, sd): self.net, self.mu, self.sd = net, mu, sd\n",
    "    def predict(self, X): return self.net.forward(X)[0] * self.sd + self.mu\n",
    "\n",
    "# ---------- 訓練一回合 ----------\n",
    "_KNOBS = ['model', 'scale', 'ridge_alpha', 'svr_C', 'svr_epsilon', 'svr_gamma', 'hidden_layers', 'neurons', 'activation', 'loss',\n",
    "          'optimizer', 'lr', 'momentum', 'batch', 'epochs', 'l2', 'dropout', 'early_stop', 'patience', 'seed', 'note', 'show', 'record']\n",
    "\n",
    "def s_run(model='NN', features=None, scale=True, ridge_alpha=1., svr_C=1., svr_epsilon=.1, svr_gamma='scale',\n",
    "          hidden_layers=1, neurons=16, activation='relu', loss='MSE', optimizer='SGD', lr=.001, momentum=.9,\n",
    "          batch=32, epochs=20, l2=0., dropout=0., early_stop=False, patience=10, seed=SEED, note='', show=True, record=True, prepare=None):\n",
    "    cfg = {k: v for k, v in locals().items() if k in _KNOBS}\n",
    "    if prepare is not None:\n",
    "        Xa = s_check_X(prepare, s_train, 'train', quiet=not show); Xb = s_check_X(prepare, s_valid, 'valid', list(Xa.columns), quiet=True)\n",
    "        cols = list(Xa.columns); prep = _Custom(prepare, cols, scale); Xa, Xb = prep.fit(Xa), prep.apply(Xb)\n",
    "    else:\n",
    "        cols = list(features or GROUPS['A'])\n",
    "        bad = [c for c in cols if c not in FEATURES]\n",
    "        if bad: raise Stop(f'⛔ 沒有這個特徵：{bad}。可以用的有：{FEATURES}')\n",
    "        prep = _Builtin(cols, scale); Xa = prep.fit(s_train[cols]); Xb = prep.apply(s_valid[cols])\n",
    "    return _fit(Xa, Xb, prep, cols, prepare is not None, cfg)\n",
    "\n",
    "class _Builtin:\n",
    "    def __init__(self, cols, scale): self.cols, self.scale = cols, scale\n",
    "    def fit(self, X):\n",
    "        self.sc = StandardScaler().fit(X) if self.scale else None; return self.apply(X)\n",
    "    def apply(self, X): return self.sc.transform(X) if self.sc else np.asarray(X, float)\n",
    "    def transform(self, frame): return self.apply(frame[self.cols])\n",
    "\n",
    "def _fit(Xa, Xb, prep, cols, custom, cfg):\n",
    "    g = cfg; ya = s_train.target.to_numpy(float); yb = s_valid.target.to_numpy(float)\n",
    "    hist, info = [], dict(steps=0, best_epoch=None, stop_epoch=None, diverged=False); t0 = time.time()\n",
    "    if g['model'] == 'Ridge':\n",
    "        clf = Ridge(alpha=g['ridge_alpha']).fit(Xa, ya); arch = f\"Ridge α={g['ridge_alpha']}\"\n",
    "    elif g['model'] == 'SVR':\n",
    "        gamma = g['svr_gamma'] if g['svr_gamma'] == 'scale' else float(g['svr_gamma'])\n",
    "        clf = SVR(C=g['svr_C'], epsilon=g['svr_epsilon'], gamma=gamma).fit(Xa, ya); arch = f\"SVR C={g['svr_C']} ε={g['svr_epsilon']} γ={g['svr_gamma']}\"\n",
    "    elif g['model'] == 'NN':\n",
    "        if g['activation'] not in ACT or g['loss'] not in ('MSE', 'MAE') or g['optimizer'] not in ('SGD', 'Momentum', 'Adam'):\n",
    "            raise Stop('⛔ ACTIVATION 選 relu／sigmoid／tanh／none；LOSS 選 MSE／MAE；OPTIMIZER 選 SGD／Momentum／Adam。')\n",
    "        mu, sd = ya.mean(), ya.std(); yz = (ya - mu) / sd                          # 答案也標準化，訓練比較穩\n",
    "        hidden = [g['neurons']] * g['hidden_layers']; net = TinyNet(Xa.shape[1], hidden, g['activation'], g['seed'])\n",
    "        opt = Optimizer(g['optimizer'], net.W + net.b, g['momentum']); rng = np.random.default_rng(g['seed'])\n",
    "        best = (np.inf, None, 0); wait = 0; reg = NetRegressor(net, mu, sd)\n",
    "        for e in range(1, g['epochs'] + 1):\n",
    "            order = rng.permutation(len(ya))\n",
    "            for s in range(0, len(ya), g['batch']):\n",
    "                idx = order[s:s + g['batch']]\n",
    "                yhat, cache = net.forward(Xa[idx], g['dropout'], train=True)\n",
    "                gW, gb = net.backward(cache, loss_grad(g['loss'], yz[idx], yhat))\n",
    "                gW = [gw + 2 * g['l2'] * W for gw, W in zip(gW, net.W)]\n",
    "                opt.step(net.W + net.b, gW + gb, g['lr']); info['steps'] += 1\n",
    "            pa, pb = reg.predict(Xa), reg.predict(Xb)\n",
    "            if not np.isfinite(pa).all() or np.abs(pa).max() > 1e4: info['diverged'] = True; break\n",
    "            vm = float(np.mean(np.abs(pb - yb))); hist.append([e, float(np.mean(np.abs(pa - ya))), vm])\n",
    "            if vm < best[0] - 1e-4: best = (vm, copy.deepcopy((net.W, net.b)), e); wait = 0\n",
    "            else: wait += 1\n",
    "            if g['early_stop'] and wait >= g['patience']: break\n",
    "        info['stop_epoch'] = hist[-1][0] if hist else 0\n",
    "        if g['early_stop'] and best[1] is not None: net.W, net.b = best[1]; info['best_epoch'] = best[2]\n",
    "        clf = reg; arch = '線性（0 層）' if g['hidden_layers'] == 0 else f\"{'×'.join(map(str, hidden))} {g['activation']}\"\n",
    "    else:\n",
    "        raise Stop(\"⛔ MODEL 選 'Ridge'、'SVR' 或 'NN'。\")\n",
    "    pa = clf.predict(Xa) if not info['diverged'] else np.zeros(len(ya)); pb = clf.predict(Xb) if not info['diverged'] else np.zeros(len(yb))\n",
    "    sc = s_score(yb, pb)\n",
    "    run = dict(id=len(s_runs), note=g['note'], model=g['model'], arch=arch, features=cols, custom=custom, prep=prep, clf=clf, pred=pb,\n",
    "               history=hist, target=(MA_DAYS, HORIZON), train_mae=float(np.mean(np.abs(pa - ya))), valid_mae=sc['mae'], rmse=sc['rmse'],\n",
    "               dir=sc['dir'], corr=sc['corr'], ratio=sc['mae'] / BASE['mae'], base_mae=BASE['mae'], cfg=dict(g),\n",
    "               after_test=s_state['locked'], seconds=time.time() - t0, **info)\n",
    "    if not g['record']: return run\n",
    "    s_runs.append(run)\n",
    "    if g['show']: s_report(run)\n",
    "    return run['id']\n",
    "\n",
    "# ---------- 目標、報告、診斷、排行榜 ----------\n",
    "MEDALS = [('🥇', .97, .73), ('🥈', 1.00, .71), ('🥉', 1.03, .69)]      # 誤差比（模型 MAE ÷ 基準②價格不動 MAE）要 ≤、方向正確率要 ≥      # 誤差比（模型 MAE ÷ 基準 MAE）要 ≤、方向正確率要 ≥\n",
    "def s_medal(run):\n",
    "    if run['target'] != OFFICIAL: return ''\n",
    "    return next((m for m, r, d in MEDALS if run['ratio'] <= r and run['dir'] >= d), '')\n",
    "\n",
    "def s_goal(run):\n",
    "    got = s_medal(run); n = len(s_valid); k = int(round(run['dir'] * n))\n",
    "    head = (f\"🎯 目標看板：驗證 MAE {run['valid_mae']:.2f} 個百分點（基準②價格不動 {run['base_mae']:.2f}，誤差比 {run['ratio']:.3f}，\" + (f\"比隨機漫步少 {1 - run['ratio']:.1%}）\" if run['ratio'] < 1 else f\"比隨機漫步多 {run['ratio'] - 1:.1%}）\")\n",
    "            + f\"｜方向正確率 {run['dir']:.1%}｜\" + (f'拿到 {got}' if got else ('還沒拿牌' if run['target'] == OFFICIAL else '探索題目，不計牌')))\n",
    "    head += (f\"\\n   算式：MAE = (1/N) Σ |ŷᵢ − yᵢ| = {run['valid_mae']:.3f}　誤差比 = {run['valid_mae']:.3f} ÷ {run['base_mae']:.3f} = {run['ratio']:.3f}\"\n",
    "             f\"　方向正確率 = {k}/{n} = {run['dir']:.3f}\")\n",
    "    if run['target'] != OFFICIAL: return head\n",
    "    nxt = [(m, r, d) for m, r, d in MEDALS[::-1] if not (run['ratio'] <= r and run['dir'] >= d)]\n",
    "    if not nxt: return head + '\\n   最高等級！換 SEED_RUN 確認不是運氣。'\n",
    "    m, r, d = nxt[0]\n",
    "    need = [f\"誤差比再降 {max(run['ratio'] - r, .001):.3f}\" if run['ratio'] > r else '誤差比 ✓',\n",
    "            f\"方向正確率再升 {max(d - run['dir'], .001):.1%}\" if run['dir'] < d else '方向 ✓']\n",
    "    return head + f'\\n   下一個 {m}（誤差比 ≤ {r}、方向正確率 ≥ {d:.0%}）：' + '、'.join(need)\n",
    "\n",
    "def s_report(run):\n",
    "    rest = [r for r in s_runs if r['id'] != run['id'] and r['target'] == run['target']]\n",
    "    best_before = min((r['valid_mae'] for r in rest), default=None)\n",
    "    fig, ax = plt.subplots(1, 2, figsize=(12, 4), layout='constrained', gridspec_kw={'width_ratios': [1.7, 1]})\n",
    "    ax[0].plot(s_valid.target_date, s_valid.target, color=GRAY, label='Actual')\n",
    "    ax[0].plot(s_valid.target_date, s_valid.baseline, color=GREEN, lw=1.2, ls='--', label='Baseline: price unchanged')\n",
    "    ax[0].plot(s_valid.target_date, run['pred'], color=NAVY, label='Prediction'); s_hits(ax[0], s_valid.target_date, s_valid.target, run['pred'])\n",
    "    ax[0].axhline(0, color='k', lw=.8); ax[0].set(ylabel='Close vs future MA (%)', title='Validation 2024'); ax[0].legend(fontsize=9); ax[0].grid(alpha=.2)\n",
    "    if run['history']:\n",
    "        h = np.array(run['history'])\n",
    "        ax[1].plot(h[:, 0], h[:, 1], color=NAVY, label='Train'); ax[1].plot(h[:, 0], h[:, 2], color=ORANGE, label='Validation')\n",
    "        ax[1].axhline(run['base_mae'], color=GREEN, ls=':', label='Baseline: price unchanged')\n",
    "        if run['target'] == OFFICIAL: ax[1].axhline(run['base_mae'] * MEDALS[0][1], color='goldenrod', ls='--', label='Gold target')\n",
    "        if run['best_epoch']: ax[1].axvline(run['best_epoch'], color='k', lw=1, ls='--')\n",
    "        ax[1].set(xlabel='Epoch', ylabel='MAE (pp)', title='MAE (lower is better)'); ax[1].legend(fontsize=9); ax[1].grid(alpha=.2)\n",
    "    else:\n",
    "        lim = [min(s_valid.target.min(), run['pred'].min()), max(s_valid.target.max(), run['pred'].max())]\n",
    "        ax[1].scatter(s_valid.target, run['pred'], s=12, color=NAVY, alpha=.6); ax[1].plot(lim, lim, color=GRAY, ls='--')\n",
    "        ax[1].set(xlabel='Actual (%)', ylabel='Prediction (%)', title='On the dashed line = perfect'); ax[1].grid(alpha=.2)\n",
    "    plt.show()\n",
    "    extra = f\"｜更新次數 steps={run['steps']}\" if run['model'] == 'NN' else ''\n",
    "    print(f\"回合 {run['id']}｜{run['arch']}｜{len(run['features'])} 個特徵｜訓練 MAE={run['train_mae']:.2f}｜驗證 MAE={run['valid_mae']:.2f}{extra}｜{run['seconds']:.1f} 秒\")\n",
    "    print(s_goal(run))\n",
    "    s_direction(s_valid.target, run['pred'])\n",
    "    for tip in s_diagnose(run, best_before): print(tip)\n",
    "    s_board(top=5, mark=run['id'])\n",
    "\n",
    "def s_diagnose(run, best_before=None):\n",
    "    tips = []\n",
    "    if run['diverged']: return ['🔴 預測爆掉（數字變無限大）：學習率太大（P20）。把 LR 調小 10 倍。']\n",
    "    if run['history'] and len(run['history']) >= 5:\n",
    "        h = np.array(run['history']); tl, vl = h[:, 1], h[:, 2]\n",
    "        if (tl[0] - tl.min() < .03 * tl[0] or tl[-1] > tl[0]) and run['train_mae'] > .6 * BASE['train_mae']:\n",
    "            tips.append('🔴 訓練誤差降不下來：學習率太大，一直跨過谷底（P20）。把 LR 調小。' if run['cfg']['lr'] >= .3 else\n",
    "                        '🟡 訓練誤差幾乎沒降：步伐太小或走太少步。試：調大 LR、增加 EPOCHS、改 Momentum／Adam。')\n",
    "        elif np.mean(np.diff(tl) > .02 * tl[:-1]) > .2:\n",
    "            tips.append('🟡 訓練誤差上下跳：步伐偏大或 batch 太小。試：調小 LR 或加大 BATCH。')\n",
    "        k = int(np.argmin(vl))\n",
    "        if k < .7 * (len(vl) - 1) and vl[-1] > vl[k] * 1.05 and not run['best_epoch']:\n",
    "            tips.append(f'🟠 驗證 MAE 在第 {int(h[k, 0])} 個 epoch 最低，之後回升：開始過擬合。試：EARLY_STOP、L2、DROPOUT、減少 NEURONS（P82–P84）。')\n",
    "        elif vl[-1] < vl[-5] - .01 and not run['best_epoch']:\n",
    "            tips.append('🟡 最後幾個 epoch 驗證 MAE 還在下降：還沒學完。試：增加 EPOCHS、調大 LR，或改 Momentum／Adam。')\n",
    "    tr_ratio = run['train_mae'] / BASE['train_mae']\n",
    "    if run['ratio'] > 1.25 * tr_ratio:\n",
    "        tips.append(f\"🟠 訓練期誤差比 {tr_ratio:.2f}、驗證期 {run['ratio']:.2f}：模型在背 2023 年。訓練只有 {len(s_train)} 天，模型越大越容易背（P77–P78）。試：L2、DROPOUT、EARLY_STOP、加大 RIDGE_ALPHA、減少特徵或神經元。\")\n",
    "    if run['ratio'] >= 1:\n",
    "        tips.append('🟡 還沒贏過基準②「價格不動」（隨機漫步）：在股票預測裡很常見。試試能補充「未來會漲還是跌」資訊的特徵，或換模型。')\n",
    "    if run['model'] == 'NN' and run['cfg']['activation'] == 'none' and run['cfg']['hidden_layers'] > 0:\n",
    "        tips.append('ℹ️ 沒有激活函數：疊幾層都等於一條直線（線性回歸）。和 relu 比一比（P48）。')\n",
    "    if run['model'] == 'NN' and run['best_epoch']:\n",
    "        tips.append(f\"ℹ️ 早停：第 {run['stop_epoch']} 個 epoch 停下，退回第 {run['best_epoch']} 個 epoch 的權重。\")\n",
    "    if run['after_test']: tips.append('ℹ️ 看過測試集之後的回合，只能當探索。')\n",
    "    if not [t for t in tips if t[0] in '🔴🟠🟡']: tips.append('🟢 曲線正常。下一步：換一個旋鈕，想辦法超越排行榜第一名。')\n",
    "    if best_before is not None and run['valid_mae'] < best_before and not run['after_test']:\n",
    "        tips.append(f\"🏆 新紀錄！驗證 MAE 從 {best_before:.3f} 降到 {run['valid_mae']:.3f}。\")\n",
    "    if best_before is not None and abs(run['valid_mae'] - best_before) < .02:\n",
    "        tips.append('ℹ️ 和第一名只差不到 0.02 個百分點：可能只是運氣，換 SEED_RUN 再跑一次（P85）。')\n",
    "    return tips\n",
    "\n",
    "def s_best():\n",
    "    ok = [r for r in s_runs if not r['after_test'] and r['target'] == (MA_DAYS, HORIZON)] or s_runs\n",
    "    return min(ok, key=lambda r: r['valid_mae'])['id']\n",
    "\n",
    "def _rid(run_id):\n",
    "    if not s_runs: raise Stop('⛔ 還沒有任何回合，先執行控制台。')\n",
    "    return s_best() if run_id in (None, -1) else run_id\n",
    "\n",
    "def s_board(top=None, mark=None):\n",
    "    if not s_runs: print('還沒有回合。'); return\n",
    "    rows = [{'回合': r['id'], '牌': s_medal(r), '備註': r['note'], '題目': f\"MA{r['target'][0]}/{r['target'][1]}天\", '模型': r['arch'],\n",
    "             '特徵數': len(r['features']), 'Epoch': r['stop_epoch'], 'L2': r['cfg']['l2'] if r['model'] == 'NN' else None,\n",
    "             '訓練MAE': r['train_mae'], '驗證MAE': r['valid_mae'], '誤差比': r['ratio'], '方向': f\"{r['dir']:.1%}\",\n",
    "             '看過測試後': '是' if r['after_test'] else ''} for r in sorted(s_runs, key=lambda r: r['ratio'])]\n",
    "    df = pd.DataFrame(rows)\n",
    "    print(f'排行榜（依誤差比；共 {len(df)} 回合）' + ('，顯示前幾名與本回合' if top else ''))\n",
    "    if top: df = pd.concat([df.head(top), df[df['回合'] == mark]]).drop_duplicates('回合')\n",
    "    compact_table(df, mark)\n",
    "\n",
    "def s_compare(ids):\n",
    "    ids = [_rid(i) for i in ids]\n",
    "    fig, ax = plt.subplots(figsize=(12, 4), layout='constrained')\n",
    "    ax.plot(s_valid.target_date, s_valid.target, color=GRAY, lw=3, label='Actual')\n",
    "    for i, c in zip(ids, [NAVY, ORANGE, GREEN, 'purple', 'brown']):\n",
    "        if s_runs[i]['target'] != (MA_DAYS, HORIZON): print(f'回合 {i} 的題目不同，略過。'); continue\n",
    "        ax.plot(s_valid.target_date, s_runs[i]['pred'], color=c, lw=1.6, label=f'Run {i}')\n",
    "    ax.axhline(0, color='k', lw=.8); ax.set(ylabel='Close vs future MA (%)'); ax.legend(); ax.grid(alpha=.2); plt.show()\n",
    "    for i in ids: print(f\"Run {i}：{s_runs[i]['note']}｜{s_runs[i]['arch']}｜驗證 MAE={s_runs[i]['valid_mae']:.3f}｜方向 {s_runs[i]['dir']:.1%}\")\n",
    "\n",
    "# ---------- 自己做特徵：只要是「今天收盤就知道」的數字就行 ----------\n",
    "class _Custom:\n",
    "    def __init__(self, fn, cols, scale): self.fn, self.cols, self.scale = fn, cols, scale\n",
    "    def fit(self, X):\n",
    "        self.sc = StandardScaler().fit(X) if self.scale else None; return self.apply(X)\n",
    "    def apply(self, X):\n",
    "        X = X.reindex(columns=self.cols).astype(float); return self.sc.transform(X) if self.sc else X.to_numpy(float)\n",
    "    def transform(self, frame): return self.apply(_features_on(self.fn, frame))\n",
    "\n",
    "def _features_on(fn, frame):\n",
    "    full = fn(stock_data[PAST_COLS].copy())\n",
    "    if isinstance(full, pd.Series): full = full.to_frame()\n",
    "    if not isinstance(full, pd.DataFrame): raise Stop('⛔ my_features 要 return 一張表（DataFrame），例如 return F。')\n",
    "    if len(full) != len(stock_data): raise Stop(f'⛔ 列數從 {len(stock_data)} 變成 {len(full)}：不要在 my_features 裡刪列（dropna）。暖身期的缺值留著，系統會用共同日期。')\n",
    "    return full.loc[frame.index]\n",
    "\n",
    "def s_check_X(fn, frame, name='train', ref=None, quiet=False):\n",
    "    X = _features_on(fn, frame).copy()\n",
    "    if X.shape[1] == 0: raise Stop('⛔ 一個特徵都沒有：至少放一欄。')\n",
    "    bad = [c for c in X.columns if any(w in str(c).lower() for w in ('target', 'next_', 'future'))]\n",
    "    if bad: raise Stop(f'⛔ {bad} 看起來是未來的資訊：預測時還不知道，這叫資料洩漏（P55）。')\n",
    "    for c in X.columns:\n",
    "        if X[c].dtype == bool: X[c] = X[c].astype(int)\n",
    "    text = [c for c in X.columns if not pd.api.types.is_numeric_dtype(X[c])]\n",
    "    if text: raise Stop(f'⛔ 這幾欄不是數字，模型吃不下：{text}。')\n",
    "    nan = [c for c in X.columns if X[c].isna().any()]\n",
    "    if nan: raise Stop(f'⛔ 這幾欄在{name}期間還有缺值：{nan}。暖身期太長（例如 rolling 超過 60 天）或除以 0？')\n",
    "    if not np.isfinite(X.to_numpy(float)).all(): raise Stop('⛔ 出現無限大：是不是除以 0 了？')\n",
    "    if name == 'train':\n",
    "        cut = len(stock_data) * 2 // 3                              # 偷看未來檢查：只給前 2/3 的資料，算出來的值應該一模一樣\n",
    "        part = fn(stock_data[PAST_COLS].iloc[:cut].copy())\n",
    "        if isinstance(part, pd.Series): part = part.to_frame()\n",
    "        full = fn(stock_data[PAST_COLS].copy())\n",
    "        if isinstance(full, pd.Series): full = full.to_frame()\n",
    "        a, b = full.iloc[:cut].astype(float), part.astype(float).reindex(columns=full.columns)\n",
    "        peek = [c for c in full.columns if not np.allclose(a[c].to_numpy(), b[c].to_numpy(), equal_nan=True)]\n",
    "        if peek: raise Stop(f'⛔ {peek} 偷看了未來：把後面的資料拿掉，過去的值竟然會變。檢查有沒有 shift(-k)、rolling(center=True) 或用了整段期間的平均（P137）。')\n",
    "        y = s_train.target.to_numpy(float)\n",
    "        leak = [c for c in X.columns if X[c].std() > 0 and abs(np.corrcoef(X[c], y)[0, 1]) > .95]\n",
    "        if leak: raise Stop(f'⛔ {leak} 幾乎等於答案（相關 > 0.95）：這是資料洩漏（P55）。')\n",
    "    if ref is not None: X = X.reindex(columns=ref)\n",
    "    return X.astype(float)\n",
    "\n",
    "# ---------- 股票挑戰控制台：特徵、模型、訓練全在同一格 ----------\n",
    "BOXES = ['BIAS_15', 'BIAS_20', 'BIAS_5', 'BIAS_8', 'MA15_slope', 'MA5_over_MA20', 'MA10_over_MA20', 'MA20_over_MA60',\n",
    "         'Return_1d', 'Return_5d', 'ROC_10', 'MACD_hist', 'Gap_open', 'Intraday_change',\n",
    "         'KD_K', 'KD_D', 'RSI', 'Williams_R', 'Bollinger_B', 'Range_day', 'Volatility_5d', 'Volume_ratio']\n",
    "_A = ['BIAS_15', 'BIAS_20', 'Return_1d']; _B = _A + ['Range_day', 'Intraday_change', 'Gap_open', 'Volume_ratio']\n",
    "_C = _B + ['MA5_over_MA20', 'MA10_over_MA20', 'MA20_over_MA60']\n",
    "STOCK_GROUPS = {'A': _A, 'B': _B, 'C': _C, 'D': _C + ['KD_K', 'KD_D', 'RSI']}     # 和投影片 P144 的 A～D 組相同\n",
    "\n",
    "def s_apply_group(group, g):\n",
    "    \"\"\"GROUP 選 A～D 時，直接把勾選換成投影片的特徵組；選「自己勾」就照表單勾選。\"\"\"\n",
    "    if group == '自己勾': return\n",
    "    for b in BOXES: g[b] = b in STOCK_GROUPS[group]\n",
    "    g['RSI_days'] = 14\n",
    "    print(f\"GROUP {group}：使用投影片的 {group} 組特徵（下面的勾選這次不看）→ {STOCK_GROUPS[group]}\")\n",
    "\n",
    "CHALLENGE_SEEDS = (42, 1, 2)\n",
    "def s_play(my_features, note='', **cfg):\n",
    "    \"\"\"挑戰控制台：檢查特徵 → 訓練（NN 用 3 個種子取平均）→ 圖、目標看板、方向表、診斷、排行榜、進步紀錄。\"\"\"\n",
    "    Xa = s_check_X(my_features, s_train, 'train')\n",
    "    print(f'✅ 資料格式正確、沒有偷看未來：訓練 {Xa.shape[0]} 天 × {Xa.shape[1]} 個特徵 → {list(Xa.columns)}')\n",
    "    seeds = CHALLENGE_SEEDS if cfg.get('model') == 'NN' else CHALLENGE_SEEDS[:1]\n",
    "    rid = s_run(prepare=my_features, seed=seeds[0], note=note, show=False, **cfg); run = s_runs[rid]\n",
    "    others = [s_run(prepare=my_features, seed=sd, show=False, record=False, **cfg) for sd in seeds[1:]]\n",
    "    if others:                                             # 神經網路：3 個種子取平均，避免靠運氣拿牌\n",
    "        allr = [dict(run)] + others\n",
    "        for k in ('valid_mae', 'rmse', 'dir', 'ratio', 'train_mae'): run[k] = float(np.mean([r[k] for r in allr]))\n",
    "        run['seed_ratios'] = [r['ratio'] for r in allr]\n",
    "    prev = tries[-1] if tries else None\n",
    "    s_report(run)\n",
    "    if others:\n",
    "        print(f\"   （神經網路：數字是 3 個種子的平均，誤差比 = ({' + '.join(f'{v:.3f}' for v in run['seed_ratios'])})/3；圖和方向表是種子 42 那一次）\")\n",
    "    tries.append(dict(no=len(tries) + 1, cols=list(Xa.columns), ratio=run['ratio'], dir=run['dir'], run=rid, medal=s_medal(run),\n",
    "                      cfg={k: v for k, v in cfg.items() if k not in ('show', 'record')}))\n",
    "    if prev:\n",
    "        add = [c for c in Xa.columns if c not in prev['cols']]; rm = [c for c in prev['cols'] if c not in Xa.columns]\n",
    "        changed = [f\"{k} {prev['cfg'].get(k)} → {v}\" for k, v in tries[-1]['cfg'].items() if prev['cfg'].get(k) != v]\n",
    "        dd = run['ratio'] - prev['ratio']\n",
    "        print(f\"📝 和上一次比：加入 {add or '—'}、移除 {rm or '—'}\" + (f\"、設定 {'、'.join(changed)}\" if changed else '') +\n",
    "              f\" → 誤差比{'下降' if dd < 0 else '上升'} {abs(dd):.3f}\" + ('　👍' if dd < -.003 else ''))\n",
    "    if len(tries) >= 2:\n",
    "        fig, ax = plt.subplots(figsize=(min(2 + .9 * len(tries), 12), 3), layout='constrained')\n",
    "        ys = [t['ratio'] for t in tries]\n",
    "        ax.bar([f\"Try {t['no']}\" for t in tries], ys, color=[GRAY] * (len(ys) - 1) + [ORANGE])\n",
    "        for (m, r, d), c, lab in zip(MEDALS, ['goldenrod', 'silver', '#CD7F32'], ['Gold', 'Silver', 'Bronze']):\n",
    "            ax.axhline(r, color=c, ls='--', lw=1.8, label=f'{lab} ratio {r}')\n",
    "        for k, (v, t) in enumerate(zip(ys, tries)):\n",
    "            ax.text(k, v + .004, f\"{v:.3f}\\ndir {t['dir']:.0%}\", ha='center', fontsize=8.5, bbox=dict(fc='white', ec='none', pad=1))\n",
    "        ax.axhline(1, color='k', lw=.8)\n",
    "        ax.set(ylim=(min(ys + [MEDALS[0][1]]) - .03, max(ys + [1.05]) + .05), ylabel='Error ratio (vs price unchanged)', title='Your tries (below 1 = beat random walk)')\n",
    "        ax.legend(fontsize=8, loc='upper left', bbox_to_anchor=(1.01, 1)); plt.show()\n",
    "\n",
    "# ---------- 最後測試、交易示範、交接 ----------\n",
    "def s_finish(run_id=-1, confirm=False):\n",
    "    if not confirm: print('先在驗證集選定回合，再把 CONFIRM 改成 True。'); return\n",
    "    if s_state['locked']:\n",
    "        print(f\"已鎖定回合 {s_state['selected']}。以下是同一份測試結果。\"); compact_table(s_state['result']); _final_trades(); return\n",
    "    rid = _rid(run_id); run = s_runs[rid]\n",
    "    if run['target'] != (MA_DAYS, HORIZON): raise Stop('⛔ 這個回合的題目和目前 B2 設定不同，先把 MA_DAYS／HORIZON 改回來。')\n",
    "    pred = run['clf'].predict(run['prep'].transform(s_test))\n",
    "    b, m, b1 = s_score(s_test.target, s_test.baseline), s_score(s_test.target, pred), s_score(s_test.target, s_test.baseline_ma)\n",
    "    result = pd.DataFrame([{'模型': '基準①均線不動', 'MAE': b1['mae'], 'RMSE': b1['rmse'], '方向正確率': b1['dir'], '相關': b1['corr']},\n",
    "                           {'模型': '基準②價格不動（隨機漫步）', 'MAE': b['mae'], 'RMSE': b['rmse'], '方向正確率': b['dir'], '相關': b['corr']},\n",
    "                           {'模型': f\"回合 {rid}：{run['arch']}\", 'MAE': m['mae'], 'RMSE': m['rmse'], '方向正確率': m['dir'], '相關': m['corr']}])\n",
    "    s_state.update(locked=True, selected=rid, pred=pred, result=result)\n",
    "    fig, ax = plt.subplots(figsize=(12, 4), layout='constrained')\n",
    "    ax.plot(s_test.target_date, s_test.target, color=GRAY, label='Actual'); ax.plot(s_test.target_date, s_test.baseline, color=GREEN, ls='--', lw=1.2, label='Baseline: price unchanged')\n",
    "    ax.plot(s_test.target_date, pred, color=NAVY, label='Prediction'); ax.axhline(0, color='k', lw=.8); s_hits(ax, s_test.target_date, s_test.target, pred)\n",
    "    ax.set(ylabel='Close vs future MA (%)', title='Final test 2025 (look once)'); ax.legend(); ax.grid(alpha=.2); plt.show()\n",
    "    compact_table(result)\n",
    "    s_direction(s_test.target, s_test.baseline, '基準②價格不動（2025）'); s_direction(s_test.target, pred, '模型（2025）')\n",
    "    r = m['mae'] / b['mae']\n",
    "    print(f\"測試誤差比 = {m['mae']:.3f} ÷ {b['mae']:.3f} = {r:.3f}（驗證是 {run['ratio']:.3f}）：\" + ('贏過隨機漫步！' if r < 1 else '沒有贏過隨機漫步：在股票預測裡很常見，代表這些特徵對未來漲跌提供的資訊很少。'))\n",
    "    print('測試集已鎖定，不再用它調參。')\n",
    "    s_state['test_pred'] = pred\n",
    "    _final_trades()\n",
    "\n",
    "def _final_trades():\n",
    "    ex = s_state.get('exit') or dict(exit_rule='持有 N 天', hold_days=7, take_profit=5., stop_loss=3., trail=5., max_days=20,\n",
    "                                     allocation=.5, cost_bps=40, capital=100000)\n",
    "    print(f\"\\n💰 2025 累積獲利：用 B6 定案的出場方式「{ex['exit_rule']}」\" + ('' if s_state.get('exit') else '（B6 沒有執行，用預設：持有 7 天）') +\n",
    "          '。粗線是定案的方式；細線只供對照，看了也不能回頭改。')\n",
    "    _trade_report(s_state['test_pred'], s_test.Date, '2025-01-01', '2026-01-01', 'Final test 2025', ex['exit_rule'], ex['hold_days'],\n",
    "                  ex['take_profit'], ex['stop_loss'], ex['trail'], ex['max_days'], ex['allocation'], ex['cost_bps'], True, ex['capital'])\n",
    "    print('2025 是沒看過的年份：如果定案的出場方式在這裡表現差很多，代表 2024 選到的可能只是剛好適合那一年。')\n",
    "\n",
    "EXITS = ['隔天收盤賣', '持有 N 天', '停利停損', '移動停損', '訊號反轉才賣']\n",
    "EXIT_EN = {'隔天收盤賣': 'Sell next close', '持有 N 天': 'Hold {hold_days} days', '停利停損': 'TP {take_profit:g}% / SL {stop_loss:g}%',\n",
    "           '移動停損': 'Trailing stop {trail:g}%', '訊號反轉才賣': 'Sell when signal flips'}\n",
    "\n",
    "def _backtest(pred, dates, start, end, exit_rule, hold_days=7, take_profit=5., stop_loss=3., trail=5., max_days=20, allocation=.5, cost_bps=40):\n",
    "    \"\"\"同一個進場訊號（收盤後預測趨勢往上 → 隔天開盤買），用不同出場規則模擬 start～end 期間。一次只持有一筆。\"\"\"\n",
    "    px = stock_data[(stock_data.Date >= start) & (stock_data.Date < end)].reset_index(drop=True)\n",
    "    up = pd.Series(np.asarray(pred) < 0, index=pd.to_datetime(np.asarray(dates))).reindex(px.Date.values).to_numpy()   # 沒有預測的日子＝NaN\n",
    "    sig = np.array([bool(v) if v == v else False for v in up]); known = np.array([v == v for v in up])\n",
    "    O, H, L, C = (px[k].to_numpy(float) for k in ('Open', 'High', 'Low', 'Close'))\n",
    "    trades, t = [], 0; n = len(px)\n",
    "    while t < n - 1:\n",
    "        if not sig[t]: t += 1; continue\n",
    "        e = t + 1; entry = O[e]; peak = entry; k = e; price = None; why = ''\n",
    "        while k < n:\n",
    "            held = k - e + 1\n",
    "            if exit_rule == '隔天收盤賣': price, why = C[k], '收盤賣'\n",
    "            elif exit_rule == '持有 N 天':\n",
    "                if held >= hold_days: price, why = C[k], f'持有 {hold_days} 天'\n",
    "            elif exit_rule == '停利停損':\n",
    "                sl, tp = entry * (1 - stop_loss / 100), entry * (1 + take_profit / 100)\n",
    "                if L[k] <= sl: price, why = min(O[k], sl) if k > e else sl, '停損'          # 同一天都碰到時，保守假設先停損\n",
    "                elif H[k] >= tp: price, why = max(O[k], tp) if k > e else tp, '停利'\n",
    "                elif held >= max_days: price, why = C[k], '時間到'\n",
    "            elif exit_rule == '移動停損':\n",
    "                peak = max(peak, C[k])\n",
    "                if C[k] <= peak * (1 - trail / 100): price, why = C[k], '移動停損'\n",
    "                elif held >= max_days: price, why = C[k], '時間到'\n",
    "            elif exit_rule == '訊號反轉才賣':\n",
    "                if known[k] and not sig[k] and k + 1 < n: k += 1; price, why = O[k], '訊號反轉'\n",
    "                elif held >= max_days: price, why = C[k], '時間到'\n",
    "            if price is not None: break\n",
    "            k += 1\n",
    "        if price is None: k = n - 1; price, why = C[k], '期末結算'\n",
    "        trades.append(dict(進場日=px.Date[e].date(), 進場價=entry, 出場日=px.Date[k].date(), 出場價=price, 天數=k - e + 1, 原因=why,\n",
    "                           報酬=price / entry - 1 - cost_bps / 10000))\n",
    "        t = k                                               # 出場那天收盤後，才能看下一個訊號\n",
    "    eq = np.full(n, 1.0); cash = 1.0\n",
    "    for tr in trades:                                       # 資金曲線：每筆投入 allocation 的資金\n",
    "        k = px.index[px.Date.dt.date == tr['出場日']][0]; cash *= 1 + allocation * tr['報酬']; eq[k:] = cash\n",
    "    r = np.array([tr['報酬'] for tr in trades]) if trades else np.array([])\n",
    "    win = r[r > 0]; loss = r[r <= 0]\n",
    "    stats = dict(出場方式=exit_rule, 交易數=len(r), 勝率=float(np.mean(r > 0)) if len(r) else np.nan,\n",
    "                 平均獲利=float(win.mean()) if len(win) else 0., 平均虧損=float(loss.mean()) if len(loss) else 0.,\n",
    "                 平均持有天數=float(np.mean([tr['天數'] for tr in trades])) if trades else np.nan)\n",
    "    stats['期望值'] = stats['勝率'] * stats['平均獲利'] + (1 - stats['勝率']) * stats['平均虧損'] if len(r) else np.nan\n",
    "    stats['總報酬'] = eq[-1] - 1; stats['最大回撤'] = float(np.min(eq / np.maximum.accumulate(eq) - 1))\n",
    "    return stats, trades, px.Date.to_numpy(), eq\n",
    "\n",
    "def _trade_report(pred, dates, start, end, label, exit_rule, hold_days, take_profit, stop_loss, trail, max_days, allocation, cost_bps,\n",
    "                  compare_all, capital):\n",
    "    kw = dict(hold_days=hold_days, take_profit=take_profit, stop_loss=stop_loss, trail=trail, max_days=max_days, allocation=allocation, cost_bps=cost_bps)\n",
    "    rules = EXITS if compare_all else [exit_rule]\n",
    "    res = {e: _backtest(pred, dates, start, end, e, **kw) for e in rules}\n",
    "    names = {e: EXIT_EN[e].format(**kw) for e in EXITS}\n",
    "    px = stock_data[(stock_data.Date >= start) & (stock_data.Date < end)]\n",
    "    fig, ax = plt.subplots(1, 2, figsize=(13, 4.2), layout='constrained', gridspec_kw={'width_ratios': [1.4, 1]})\n",
    "    bh_eq = capital * (1 + allocation * (px.Close / px.Close.iloc[0] - 1))\n",
    "    ax[0].plot(px.Date, bh_eq, color=GRAY, lw=2.5, label=f'Buy & hold ({allocation:.0%} of money)')\n",
    "    ends = [(px.Date.iloc[-1], bh_eq.iloc[-1], GRAY)]\n",
    "    for (e, (st, tr, d, eq)), c in zip(res.items(), [NAVY, ORANGE, GREEN, 'purple', 'brown']):\n",
    "        ax[0].plot(d, capital * eq, color=c, lw=3 if e == exit_rule else 1.4, alpha=1 if e == exit_rule else .75, label=names[e])\n",
    "        ends.append((d[-1], capital * eq[-1], c))\n",
    "    lo, hi = min(v for _, v, _ in ends), max(v for _, v, _ in ends); gap = max(hi - lo, capital * .1) * .055; last = -np.inf\n",
    "    for x, v, c in sorted(ends, key=lambda e: e[1]):          # 尾端數字由下往上排，太近就往上錯開\n",
    "        y = max(v, last + gap); last = y\n",
    "        ax[0].annotate(f'{v - capital:+,.0f} ({v / capital - 1:+.0%})', (x, v), xytext=(x + pd.Timedelta(days=4), y), va='center', fontsize=8, color=c)\n",
    "    ax[0].axhline(capital, color='k', lw=.8)\n",
    "    ax[0].set(ylabel='Account value (TWD)', title=f'{label}: cumulative profit from {capital:,.0f} TWD')\n",
    "    ax[0].yaxis.set_major_formatter(matplotlib.ticker.StrMethodFormatter('{x:,.0f}')); ax[0].set_xlim(right=px.Date.iloc[-1] + pd.Timedelta(days=70))\n",
    "    ax[0].legend(fontsize=8, loc='upper left'); ax[0].grid(alpha=.2)\n",
    "    st, tr, d, eq = res[exit_rule]\n",
    "    ax[1].plot(px.Date, px.Close, color=GRAY)\n",
    "    if tr:\n",
    "        ax[1].scatter([pd.Timestamp(x['進場日']) for x in tr], [x['進場價'] for x in tr], marker='^', color=GREEN, s=28, label='Buy', zorder=3)\n",
    "        ax[1].scatter([pd.Timestamp(x['出場日']) for x in tr], [x['出場價'] for x in tr], marker='v', color='#C62828', s=28, label='Sell', zorder=3)\n",
    "    ax[1].set(ylabel='Close (TWD)', title=f'Trades: {names[exit_rule]}'); ax[1].legend(fontsize=8); ax[1].grid(alpha=.2); plt.show()\n",
    "    tab = pd.DataFrame([r[0] for r in res.values()]); show = tab.copy()\n",
    "    for k in ('勝率', '平均獲利', '平均虧損', '期望值', '總報酬', '最大回撤'): show[k] = show[k].map(lambda v: f'{v:+.2%}' if k != '勝率' else f'{v:.0%}')\n",
    "    show['平均持有天數'] = show['平均持有天數'].map(lambda v: f'{v:.1f}')\n",
    "    show.insert(1, '累積獲利（元）', tab['總報酬'].map(lambda v: f'{capital * v:+,.0f}'))\n",
    "    display(HTML(show.to_html(index=False)))\n",
    "    bh = px.Close.iloc[-1] / px.Close.iloc[0] - 1\n",
    "    print(f'起始資金 {capital:,.0f} 元。對照：一直持有（買進後不賣）報酬 {bh:+.1%}；只投入 {allocation:.0%} 資金時約 {allocation * bh:+.1%}（{capital * allocation * bh:+,.0f} 元）。')\n",
    "    print('累積獲利：' + '｜'.join(f\"{r[0]['出場方式']} {capital * r[0]['總報酬']:+,.0f} 元\" for r in res.values()))\n",
    "    print(f\"期望值 = 勝率 × 平均獲利 + 敗率 × 平均虧損（P151）。{exit_rule}：{st['勝率']:.0%} × {st['平均獲利']:+.2%} + {1 - st['勝率']:.0%} × {st['平均虧損']:+.2%} = {st['期望值']:+.2%}／筆\")\n",
    "    return tab, tr\n",
    "\n",
    "def stock_trade_demo(run_id=-1, exit_rule='隔天收盤賣', hold_days=7, take_profit=5., stop_loss=3., trail=5., max_days=20,\n",
    "                     allocation=.5, cost_bps=40, compare_all=True, capital=100000):\n",
    "    if exit_rule not in EXITS: raise Stop(f'⛔ EXIT 選 {EXITS}')\n",
    "    run = s_runs[_rid(run_id)]\n",
    "    if s_state['locked']:                                   # 測試集已開：還是可以看 2024，但不能再改 B7 定案的出場方式\n",
    "        ex = s_state.get('exit', {}).get('exit_rule', '持有 N 天')\n",
    "        print(f'ℹ️ 測試集（2025）已經開啟：這裡只畫 2024 驗證期給你看；B7 定案的出場方式「{ex}」不會因為這裡的設定而改變。')\n",
    "    else:\n",
    "        s_state['exit'] = dict(exit_rule=exit_rule, hold_days=hold_days, take_profit=take_profit, stop_loss=stop_loss, trail=trail,\n",
    "                               max_days=max_days, allocation=allocation, cost_bps=cost_bps, capital=capital)      # B7 沿用這組出場設定\n",
    "    print(f\"回合 {run['id']}（{run['arch']}）的訊號；2024 驗證期；每筆投入 {allocation:.0%} 資金；每筆來回成本 {cost_bps} bp（含滑價假設）。\")\n",
    "    print('進場都一樣：收盤後預測「7 天後的均線比今天收盤價高」→ 隔天開盤買。差別只在什麼時候賣。')\n",
    "    tab, tr = _trade_report(run['pred'], s_valid.Date, '2024-01-01', '2025-01-01', 'Validation 2024', exit_rule, hold_days, take_profit,\n",
    "                            stop_loss, trail, max_days, allocation, cost_bps, compare_all, capital)\n",
    "    print('看表格想一想：勝率高的出場方式，總報酬一定最高嗎？停損讓虧損變小，但會不會也砍掉很多本來會漲回來的交易？')\n",
    "    print('這是 2024 驗證期的教學模擬（未還原權息、可分割部位、成本為假設值），不是投資建議；出場參數也不能一直調到最好看（P85）。')\n",
    "    if not s_state['locked']: print(f'B7 最後測試會沿用這次的出場設定：{exit_rule}。')\n",
    "    if tr: compact_table(pd.DataFrame(tr).head(8).assign(報酬=lambda d: d['報酬'].map(lambda v: f'{v:+.2%}')))\n",
    "    return tab\n",
    "\n",
    "def export_handoff(filename='Class2_to_Class3.zip'):\n",
    "    if not s_state['locked']: raise Stop('⛔ 先完成最後測試，才匯出交接檔。')\n",
    "    run = s_runs[s_state['selected']]\n",
    "    config = dict(model=run['model'], arch=run['arch'], features=run['features'], custom_features=run['custom'],\n",
    "                  settings={k: v for k, v in run['cfg'].items() if k not in ('show', 'record')}, seed=SEED, stock='2330',\n",
    "                  target=f'(Close_t / MA{MA_DAYS} at t+{HORIZON} - 1) x 100', ma_days=MA_DAYS, horizon=HORIZON,\n",
    "                  train='feature_date < 2024-01-01 AND target_date < 2024-01-01',\n",
    "                  validation='2024-01-01 <= feature_date < 2025-01-01 AND target_date < 2025-01-01',\n",
    "                  test='feature_date >= 2025-01-01', row_policy='rows where all built-in features and target are known',\n",
    "                  prices='unadjusted TWD OHLC; volume in shares', rsi='14-day simple average, not Wilder', kd='RSV9; K,D recursive 1/3; initialize 50',\n",
    "                  versions={'numpy': np.__version__, 'pandas': pd.__version__, 'sklearn': sklearn_version})\n",
    "    payload = stock_data.to_csv(index=False).encode('utf-8-sig'); config['features_csv_sha256'] = hashlib.sha256(payload).hexdigest()\n",
    "    with zipfile.ZipFile(filename, 'w', zipfile.ZIP_DEFLATED) as z:\n",
    "        z.writestr('features.csv', payload); z.writestr('config.json', json.dumps(config, ensure_ascii=False, indent=2))\n",
    "        z.writestr('test_metrics.csv', s_state['result'].to_csv(index=False))\n",
    "        z.writestr('test_predictions.csv', pd.DataFrame({'target_date': s_test.target_date, 'actual': s_test.target,\n",
    "                                                         'prediction': s_state['pred'], 'baseline_price_unchanged': s_test.baseline, 'baseline_ma_unchanged': s_test.baseline_ma}).to_csv(index=False))\n",
    "    print('已產生', filename, '：從左側檔案區下載，Class 3 沿用。')\n",
    "\n",
    "# ---------- 練習檢查器：老師示範一行，換你寫一行，答對才能往下 ----------\n",
    "class _Blank:\n",
    "    def __repr__(self): return '___'\n",
    "___ = _Blank()\n",
    "_passed, _tries = set(), {}\n",
    "\n",
    "def need(prev):\n",
    "    if prev not in _passed: raise Stop(f'⛔ 先完成 {prev}（上一格要出現 ✅），才能做這一格。')\n",
    "\n",
    "def _same(a, b):\n",
    "    try:\n",
    "        a, b = np.asarray(a, float), np.asarray(b, float)\n",
    "        return a.shape == b.shape and np.allclose(a, b, equal_nan=True)\n",
    "    except Exception: return False\n",
    "\n",
    "def _num(s): return pd.to_numeric(s.astype(str).str.replace(',', '', regex=False))\n",
    "\n",
    "def _ex():\n",
    "    G = globals(); df = G.get('df')\n",
    "    def ref():                                        # 參考答案：照 S1–S6 的步驟重算一次\n",
    "        r = stock_raw.copy()\n",
    "        for c in ['Open', 'High', 'Low', 'Close', 'Volume']: r[c] = _num(r[c])\n",
    "        r['Date'] = pd.to_datetime(r['Date']); r = r.sort_values('Date').reset_index(drop=True)\n",
    "        r['MA15'] = r['Close'].rolling(15).mean(); r['BIAS15'] = (r['Close'] / r['MA15'] - 1) * 100\n",
    "        r['target'] = (r['Close'] / r['MA15'].shift(-7) - 1) * 100\n",
    "        return r\n",
    "    R = ref()\n",
    "    return {\n",
    "    'S1': ('pd.to_numeric(df[\"Volume\"].str.replace(\",\", \"\"))',\n",
    "           lambda v: (_same(v, _num(stock_raw['Volume'])), '提示：照上一行，把 \"Close\" 換成 \"Volume\"。'),\n",
    "           '成交量從文字 \"23,562,105\" 變成數字 23562105。'),\n",
    "    'S2': ('df.sort_values(\"Date\").reset_index(drop=True)',\n",
    "           lambda v: (isinstance(v, pd.DataFrame) and len(v) == len(stock_raw) and v['Date'].is_monotonic_increasing,\n",
    "                      '提示：sort_values(\"Date\") 由舊到新排序；reset_index(drop=True) 讓列號重新從 0 開始。'),\n",
    "           '時間序列一定要照日期排好，rolling 和 shift 才會對。'),\n",
    "    'S3': ('df[\"Close\"].rolling(15).mean()',\n",
    "           lambda v: (_same(v, R['MA15']), '提示：照上一行，把 5 換成 15。'),\n",
    "           '前 14 天湊不滿 15 天，是缺值：這叫暖身期。'),\n",
    "    'S4': ('(df[\"Close\"] / df[\"MA15\"] - 1) * 100',\n",
    "           lambda v: (_same(v, R['BIAS15']), '提示：照上一行，把 MA5 換成 MA15（P134）。'),\n",
    "           '乖離率＝收盤價比均線高（正）或低（負）百分之幾。'),\n",
    "    'S5': ('(df[\"High\"] - df[\"Low\"]) / df[\"Close\"] * 100',\n",
    "           lambda v: (_same(v, (R['High'] - R['Low']) / R['Close'] * 100), '提示：(最高 − 最低) ÷ 收盤 × 100；分子要加括號。'),\n",
    "           '當天震幅（%）：一個自己造的新特徵。'),\n",
    "    'S6': ('(df[\"Close\"] / df[\"MA15_future\"] - 1) * 100',\n",
    "           lambda v: (_same(v, R['target']),\n",
    "                      '那是今天的乖離率（BIAS15）。答案要比的是 7 天後的均線 MA15_future。' if _same(v, R['BIAS15']) else\n",
    "                      '提示：和乖離率同一個公式，只是分母換成 MA15_future（P137）。'),\n",
    "           '最後 7 天的答案是缺值：未來還沒發生，本來就不知道。'),\n",
    "    'S7': ('(data[\"target\"] - data[\"BIAS15\"]).abs().mean()',\n",
    "           lambda v: (_same(v, (G['data']['target'] - G['data']['BIAS15']).abs().mean()),\n",
    "                      '提示：基準的預測是 BIAS15，誤差＝答案 − 預測，取絕對值再平均（P140）。'),\n",
    "           '這就是要打敗的數字：模型的 MAE 要比它小，才算學到東西。'),\n",
    "    'S8': ('data[(data[\"Date\"] >= \"2024-01-01\") & (data[\"Date\"] < \"2025-01-01\")]',\n",
    "           lambda v: (isinstance(v, pd.DataFrame) and len(v) > 0 and v['Date'].min() >= pd.Timestamp('2024-01-01') and v['Date'].max() < pd.Timestamp('2025-01-01')\n",
    "                      and len(v) == ((G['data']['Date'] >= '2024-01-01') & (G['data']['Date'] < '2025-01-01')).sum(),\n",
    "                      '提示：兩個日期條件都加括號，用 & 連接：2024-01-01 之後（含）、2025-01-01 之前。'),\n",
    "           '股票要照時間切：用過去訓練、用未來驗證。隨機切等於拿 2024 年的答案去學 2023 年。'),\n",
    "    'S9': ('(valid[\"return1\"] - mu) / sd',\n",
    "           lambda v: (_same(v, (G['valid']['return1'] - G['train']['return1'].mean()) / G['train']['return1'].std()),\n",
    "                      '❗ 你用了驗證期自己的平均和標準差：那是「未來」的資訊（P56）。要用訓練期算好的 mu、sd。'\n",
    "                      if _same(v, (G['valid']['return1'] - G['valid']['return1'].mean()) / G['valid']['return1'].std()) else\n",
    "                      '提示：公式 (x − 平均) ÷ 標準差，x 換成 valid[\"return1\"]，平均和標準差用 mu、sd。'),\n",
    "           '標準化的尺，只能用過去（訓練期）量出來。'),\n",
    "    }\n",
    "\n",
    "def check(eid, value):\n",
    "    answer_line, fn, why = _ex()[eid]\n",
    "    if isinstance(value, _Blank) or (isinstance(value, tuple) and any(isinstance(v, _Blank) for v in value)) or \\\n",
    "            (isinstance(value, pd.Series) and len(value) and isinstance(value.iloc[0], _Blank)):\n",
    "        print(f'✏️ {eid}：把 ___ 換成你的程式，再按 ▶。'); return\n",
    "    try: ok, hint = fn(value)\n",
    "    except Exception as e: ok, hint = False, f'提示：程式有錯（{type(e).__name__}）。檢查欄名和括號。'\n",
    "    if ok:\n",
    "        _passed.add(eid); print(f'✅ {eid} 正確！{why}')\n",
    "        if eid == 'S9': print('🎉 資料處理練習完成！接下來 B2 用同樣的方法建出全部特徵，B4 開始訓練。')\n",
    "        return\n",
    "    _tries[eid] = _tries.get(eid, 0) + 1\n",
    "    print(f'❌ {eid} 還不對。{hint}')\n",
    "\n",
    "def answer(eid):\n",
    "    print(f'{eid} 參考答案：', _ex()[eid][0])\n",
    "\n",
    "\n",
    "stock_raw = pd.read_csv(io.BytesIO(gzip.decompress(base64.b64decode('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'))))\n",
    "print('工具與內附資料已載入（台積電 2330，2023–2025）。sklearn', sklearn_version)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f977d50c",
   "metadata": {},
   "source": [
    "## B1–B3｜資料處理（已填好答案，直接執行）\n",
    "\n",
    "每一格上面是 👀 示範，✅ 那一行是答案，執行後應該都出現 ✅。上課時老師會挑幾格講解。\n",
    "想自己練習：把 ✅ 那一行改成別的寫法再按 ▶，檢查器會告訴你對不對。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "70f29e85",
   "metadata": {},
   "source": [
    "### B1-①　文字變數字（P132）\n",
    "\n",
    "TWSE 的價格是帶逗號的文字，例如 `\"1,050.00\"`：先去掉逗號，再轉成數字。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0daec3ec",
   "metadata": {},
   "outputs": [],
   "source": [
    "display(stock_raw.head(3)); print(stock_raw.dtypes)      # 全部都是文字（str）\n",
    "df = stock_raw.copy()\n",
    "for c in [\"Open\", \"High\", \"Low\", \"Close\"]:\n",
    "    df[c] = pd.to_numeric(df[c].str.replace(\",\", \"\"))      # 👀 示範：去逗號 → 轉數字\n",
    "\n",
    "# ✅ 已填好：成交量 Volume 也轉成數字\n",
    "df[\"Volume\"] = pd.to_numeric(df[\"Volume\"].str.replace(\",\", \"\"))\n",
    "check(\"S1\", df[\"Volume\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "17833772",
   "metadata": {},
   "source": [
    "### B1-②　日期與排序\n",
    "\n",
    "時間序列一定要照日期由舊到新排好，後面的 rolling、shift 才會對。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23e5eef9",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S1\")\n",
    "df[\"Date\"] = pd.to_datetime(df[\"Date\"])                   # 👀 示範：文字變日期\n",
    "\n",
    "# ✅ 已填好：依 Date 由舊到新排序，並重設列號\n",
    "df = df.sort_values(\"Date\").reset_index(drop=True)\n",
    "check(\"S2\", df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3b1388f9",
   "metadata": {},
   "source": [
    "### B2-①　移動平均（P133）\n",
    "\n",
    "`rolling(n).mean()`：每一天往回看 n 天（含今天）取平均。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2e86b950",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S2\")\n",
    "df[\"MA5\"] = df[\"Close\"].rolling(5).mean()                  # 👀 示範：5 日均線\n",
    "\n",
    "# ✅ 已填好：15 日均線\n",
    "df[\"MA15\"] = df[\"Close\"].rolling(15).mean()\n",
    "check(\"S3\", df[\"MA15\"])\n",
    "display(df[[\"Date\", \"Close\", \"MA5\", \"MA15\"]].iloc[12:17])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d50397d6",
   "metadata": {},
   "source": [
    "### B2-②　乖離率（P134）\n",
    "\n",
    "乖離率＝(收盤價 ÷ 均線 − 1) × 100：收盤價比均線高（正）或低（負）百分之幾。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "847236c4",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S3\")\n",
    "df[\"BIAS5\"] = (df[\"Close\"] / df[\"MA5\"] - 1) * 100          # 👀 示範：5 日乖離率\n",
    "\n",
    "# ✅ 已填好：15 日乖離率\n",
    "df[\"BIAS15\"] = (df[\"Close\"] / df[\"MA15\"] - 1) * 100\n",
    "check(\"S4\", df[\"BIAS15\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3aa24ec7",
   "metadata": {},
   "source": [
    "### B2-③　自己造特徵（P135）\n",
    "\n",
    "整欄一起算，不用寫迴圈。當天震幅＝(最高 − 最低) ÷ 收盤 × 100。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c0350a68",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S4\")\n",
    "df[\"return1\"] = df[\"Close\"].pct_change() * 100              # 👀 示範：今天漲跌幅（%）\n",
    "\n",
    "# ✅ 已填好：當天震幅（%）\n",
    "df[\"range_pct\"] = (df[\"High\"] - df[\"Low\"]) / df[\"Close\"] * 100\n",
    "check(\"S5\", df[\"range_pct\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dbd84a0e",
   "metadata": {},
   "source": [
    "### B2-④　建立答案：把 7 天後的均線拉回今天（P137）\n",
    "\n",
    "`shift(-7)` 把 7 天後那一列的值往上搬到今天這一列。只有「答案」可以用未來的值，特徵絕對不行。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17c1e891",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S5\")\n",
    "df[\"MA15_future\"] = df[\"MA15\"].shift(-7)                   # 👀 示範：7 天後的 15 日均線，放到今天這一列\n",
    "\n",
    "# ✅ 已填好：答案＝今天收盤價相對 7 天後均線的乖離率\n",
    "df[\"target\"] = (df[\"Close\"] / df[\"MA15_future\"] - 1) * 100\n",
    "check(\"S6\", df[\"target\"])\n",
    "display(df[[\"Date\", \"Close\", \"MA15\", \"MA15_future\", \"target\"]].tail(9))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "70e535ef",
   "metadata": {},
   "source": [
    "### B3-①　基準：要打敗的數字（P139、P140）\n",
    "\n",
    "基準①的預測＝今天的乖離率 BIAS15（假設均線一週內不動）。MAE＝|答案 − 預測| 的平均。這裡先用三年全部資料算；B2 會再加一個更強的基準②「價格不動」，只看 2024 驗證期。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c4a034d4",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S6\")\n",
    "data = df.dropna(subset=[\"BIAS15\", \"target\"]).copy()       # 暖身期和最後 7 天沒有答案，先拿掉\n",
    "mae_zero = data[\"target\"].abs().mean()                      # 👀 示範：如果每天都猜 0\n",
    "print(\"每天猜 0 的 MAE：\", round(mae_zero, 2))\n",
    "\n",
    "# ✅ 已填好：基準（預測＝BIAS15）的 MAE\n",
    "mae_base = (data[\"target\"] - data[\"BIAS15\"]).abs().mean()\n",
    "check(\"S7\", mae_base)\n",
    "print(\"基準 MAE：\", round(mae_base, 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ee1d2aec",
   "metadata": {},
   "source": [
    "### B3-②　照時間切分（P138）\n",
    "\n",
    "用過去訓練、用未來驗證。不能隨機切：那等於拿 2024 年的資訊去學 2023 年。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e5997f5d",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S7\")\n",
    "train = data[data[\"Date\"] < \"2024-01-01\"]                  # 👀 示範：2023 年當訓練\n",
    "\n",
    "# ✅ 已填好：2024 年當驗證\n",
    "valid = data[(data[\"Date\"] >= \"2024-01-01\") & (data[\"Date\"] < \"2025-01-01\")]\n",
    "check(\"S8\", valid)\n",
    "print(\"訓練\", len(train), \"天；驗證\", len(valid), \"天\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c60fffc2",
   "metadata": {},
   "source": [
    "### B3-③　標準化只用過去量尺（P62）\n",
    "\n",
    "平均和標準差只能從訓練期算，驗證期套用同一把尺。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5877a0fa",
   "metadata": {},
   "outputs": [],
   "source": [
    "need(\"S8\")\n",
    "mu, sd = train[\"return1\"].mean(), train[\"return1\"].std()   # 👀 示範：訓練期的平均、標準差\n",
    "train_z = (train[\"return1\"] - mu) / sd\n",
    "\n",
    "# ✅ 已填好：驗證期也標準化\n",
    "valid_z = (valid[\"return1\"] - mu) / sd\n",
    "check(\"S9\", valid_z)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d0c016ce",
   "metadata": {},
   "source": [
    "## B2｜🎛️ 設定題目，建出全部特徵（P147、P136）\n",
    "\n",
    "工具用剛才練習的同一套方法，一次算出全部特徵（MA、乖離率、報酬、震幅、成交量比、均線比、KD、RSI），再照時間切分。\n",
    "正式題目是 15 日均線、7 天後，只有正式題目計牌。改天數可以探索「已知的天數越多，越容易猜準嗎？」\n",
    "\n",
    "**這一格只建立資料和答案，還沒有訓練。** 改了 `MA_DAYS`、`HORIZON` 之後，下面所有格子（B4 挑戰控制台、B6 出場策略、B7 最後測試）都會改用新題目（要重跑那些格子才會更新），直到你再改回來；B4 的 `BIAS_15`、`MA15_slope` 也會跟著換成新的均線天數。排行榜會記錄每個回合是哪個題目，不同題目的回合不能直接比，也不計牌。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20ab0e98",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 題目\n",
    "MA_DAYS = 15  #@param {type:\"slider\", min:5, max:30, step:5}\n",
    "HORIZON = 7  #@param {type:\"slider\", min:1, max:15, step:1}\n",
    "s_setup(MA_DAYS, HORIZON)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "71219d8f",
   "metadata": {},
   "source": [
    "## B4｜🏆 股票挑戰控制台（P144–P148）\n",
    "\n",
    "**一格搞定**：勾選特徵（22 個技術指標，或直接選投影片的 A～D 組）、選模型、調訓練，按 ▶ 就會看到：\n",
    "①2024 驗證期的預測曲線（綠點＝方向猜對、紅叉＝猜錯）；②神經網路的訓練／驗證 MAE 曲線（Ridge、SVR 則是散佈圖）；③🎯 目標看板；④方向對照表；⑤診斷訊息；⑥排行榜；⑦和上一次比改了什麼、進步多少。\n",
    "\n",
    "預設是一個**還沒練好**的網路（SGD、學習率 0.001、20 個 epoch）。用課堂教過的方法，把誤差壓下去、拿到 🥇。\n",
    "\n",
    "**兩個基準**（B2 的圖上兩條線）：\n",
    "- 基準①**均線不動**：直接拿今天的乖離率當預測（P139）。它以為均線一週內不動，但未來 7 天的新收盤價一定會把均線拉向現在的價格，所以很弱：驗證 MAE 2.77，連「每天猜 0」（1.78）都不如。\n",
    "- 基準②**價格不動**（隨機漫步）：假設未來 7 天的收盤價都和今天一樣，代進 7 天後的 15 日均線。不用訓練就有 MAE 1.28、方向 72.8%。**這才是真正要贏的對手**。\n",
    "\n",
    "**🎯 目標看板**（只算正式題目：15 日均線、7 天後；神經網路用 3 個亂數種子平均）：誤差比和方向正確率兩個都達標才拿牌。\n",
    "\n",
    "| 牌 | 誤差比（模型 MAE ÷ 基準② MAE） | 方向正確率 | 意思 |\n",
    "|---|---|---|---|\n",
    "| 🥉 | ≤ 1.03 | ≥ 69% | 和隨機漫步差不多 |\n",
    "| 🥈 | ≤ 1.00 | ≥ 71% | 贏過隨機漫步 |\n",
    "| 🥇 | ≤ 0.97 | ≥ 73% | 誤差和方向都明顯贏過隨機漫步 |\n",
    "\n",
    "**數字怎麼算**（驗證期 N 天；$y_i$ 是答案，$\\hat y_i$ 是模型預測，$C_t$ 是第 t 天收盤價）\n",
    "\n",
    "基準②價格不動：7 天後的 15 日均線裡，有 8 天（$t-7$ 到 $t$）今天已經知道，剩下 7 天都填今天的收盤價：\n",
    "\n",
    "$$b_t=\\left(\\frac{C_t}{\\left(\\sum_{j=0}^{7}C_{t-j}+7\\,C_t\\right)/15}-1\\right)\\times 100$$\n",
    "\n",
    "$$\\text{MAE}=\\frac{1}{N}\\sum_{i=1}^{N}\\left|\\hat y_i-y_i\\right| \\qquad \\text{MAE}_{b}=\\frac{1}{N}\\sum_{i=1}^{N}\\left|b_i-y_i\\right|$$\n",
    "\n",
    "$$r=\\frac{\\text{MAE}}{\\text{MAE}_{b}} \\qquad D=\\frac{1}{N}\\sum_{i=1}^{N}\\mathbb{1}\\left[\\operatorname{sign}(\\hat y_i)=\\operatorname{sign}(y_i)\\right]$$\n",
    "\n",
    "- 誤差比 $r$：小於 1 才算贏過隨機漫步；0.96 表示誤差比它少 4%。股票很難預測，能穩定贏過隨機漫步一點點就很不容易。用比值也是因為 2024 年波動比 2023 年大，直接看 MAE 會被年份影響（P80）。\n",
    "- 方向正確率 $D$：預測和答案同號的比例。答案 < 0 表示 7 天後的均線會比今天收盤價高，也就是趨勢往上（P134）。\n",
    "- 方向對照表裡「說往上時猜中幾成」就是 Precision，和鐵達尼號的混淆矩陣是同一件事（P90–P91）。\n",
    "\n",
    "**會自動檢查**：格式（是不是數字、有沒有缺值）、**有沒有偷看未來**（只給前 2/3 的日子重算一次，過去的值變了就是用到未來，例如 `shift(-7)`）、有沒有和答案幾乎一樣。\n",
    "勾選裡沒有的特徵（包括外資、比特幣等外部資料），可以直接寫進 `my_features` 最下面（P148）。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f5827b2d",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🏆 股票挑戰控制台：勾選特徵、調模型，再按 ▶（每按一次，排行榜多一個回合）\n",
    "NOTE = '預設設定'  #@param {type:\"string\"}\n",
    "#@markdown ---\n",
    "#@markdown ### ① 特徵（P144、P136）：GROUP 選 A～D 會直接套用投影片的特徵組；選「自己勾」才看下面的勾選\n",
    "GROUP = '自己勾'  #@param ['自己勾', 'A', 'B', 'C', 'D']\n",
    "#@markdown **價格位置**：BIAS_15／20／5／8：15／20／5／8 日乖離率（BIAS_15 和 MA15_slope 的 15 會跟著 B2 的 MA_DAYS 變）；MA15_slope：15 日均線 5 天的變化；MA5_over_MA20 等：短均線 ÷ 長均線\n",
    "BIAS_15 = True  #@param {type:\"boolean\"}\n",
    "BIAS_20 = True  #@param {type:\"boolean\"}\n",
    "BIAS_5 = False  #@param {type:\"boolean\"}\n",
    "BIAS_8 = False  #@param {type:\"boolean\"}\n",
    "MA15_slope = False  #@param {type:\"boolean\"}\n",
    "MA5_over_MA20 = False  #@param {type:\"boolean\"}\n",
    "MA10_over_MA20 = False  #@param {type:\"boolean\"}\n",
    "MA20_over_MA60 = False  #@param {type:\"boolean\"}\n",
    "#@markdown **動能**：Return_1d／5d：1 日／5 日報酬率；ROC_10：10 日變動率；MACD_hist：MACD 柱狀體（12, 26, 9）；Gap_open：跳空缺口（開盤對昨收）；Intraday_change：日內漲跌（收盤對開盤）\n",
    "Return_1d = True  #@param {type:\"boolean\"}\n",
    "Return_5d = False  #@param {type:\"boolean\"}\n",
    "ROC_10 = False  #@param {type:\"boolean\"}\n",
    "MACD_hist = False  #@param {type:\"boolean\"}\n",
    "Gap_open = False  #@param {type:\"boolean\"}\n",
    "Intraday_change = False  #@param {type:\"boolean\"}\n",
    "#@markdown **擺盪指標**：KD_K／KD_D：KD 隨機指標（9 日）；RSI：相對強弱指標；Williams_R：威廉指標 %R；Bollinger_B：布林通道 %B（20 日）\n",
    "KD_K = False  #@param {type:\"boolean\"}\n",
    "KD_D = False  #@param {type:\"boolean\"}\n",
    "RSI = False  #@param {type:\"boolean\"}\n",
    "RSI_days = 14  #@param {type:\"slider\", min:5, max:30, step:1}\n",
    "Williams_R = False  #@param {type:\"boolean\"}\n",
    "Williams_days = 14  #@param {type:\"slider\", min:5, max:30, step:1}\n",
    "Bollinger_B = False  #@param {type:\"boolean\"}\n",
    "#@markdown **波動與成交量**：Range_day：當日震幅；Volatility_5d：5 日報酬波動；Volume_ratio：量比（成交量 ÷ 20 日均量）\n",
    "Range_day = False  #@param {type:\"boolean\"}\n",
    "Volatility_5d = False  #@param {type:\"boolean\"}\n",
    "Volume_ratio = False  #@param {type:\"boolean\"}\n",
    "SCALE = True  #@param {type:\"boolean\"}\n",
    "#@markdown SCALE：特徵標準化（P62）\n",
    "#@markdown ### ② 模型：Ridge（線性＋L2，P82）、SVR（P128）、NN 神經網路\n",
    "MODEL = 'NN'  #@param ['NN', 'Ridge', 'SVR']\n",
    "RIDGE_ALPHA = 1.0  #@param [0.0, 0.1, 1.0, 10.0, 100.0] {type:\"raw\"}\n",
    "SVR_C = 1.0  #@param [0.1, 1.0, 10.0, 100.0] {type:\"raw\"}\n",
    "SVR_EPSILON = 0.1  #@param [0.01, 0.1, 0.5, 1.0] {type:\"raw\"}\n",
    "SVR_GAMMA = 'scale'  #@param ['scale', '0.01', '0.1', '1']\n",
    "#@markdown ### ③ 神經網路結構（MODEL＝NN 時；0 層＝線性回歸，P48）\n",
    "HIDDEN_LAYERS = 1  #@param {type:\"slider\", min:0, max:3, step:1}\n",
    "NEURONS = 16  #@param [2, 4, 8, 16, 32, 64, 128] {type:\"raw\"}\n",
    "ACTIVATION = 'relu'  #@param ['relu', 'sigmoid', 'tanh', 'none']\n",
    "#@markdown ### ④ 損失與梯度下降（P140、P20、P19、P52）\n",
    "LOSS = 'MSE'  #@param ['MSE', 'MAE']\n",
    "OPTIMIZER = 'SGD'  #@param ['SGD', 'Momentum', 'Adam']\n",
    "LR = 0.001  #@param [0.0001, 0.001, 0.01, 0.03, 0.1, 1.0] {type:\"raw\"}\n",
    "MOMENTUM = 0.9  #@param {type:\"slider\", min:0, max:0.99, step:0.01}\n",
    "BATCH = 32  #@param [1, 8, 32, 173] {type:\"raw\"}\n",
    "EPOCHS = 20  #@param {type:\"slider\", min:1, max:300, step:1}\n",
    "#@markdown BATCH＝173 是整個訓練期一次算（full batch）；神經網路會自動用 3 個亂數種子取平均。\n",
    "#@markdown ### ⑤ 泛化（P82–P84）\n",
    "L2 = 0  #@param [0, 0.0001, 0.001, 0.01, 0.1] {type:\"raw\"}\n",
    "DROPOUT = 0.0  #@param {type:\"slider\", min:0, max:0.8, step:0.1}\n",
    "EARLY_STOP = False  #@param {type:\"boolean\"}\n",
    "PATIENCE = 10  #@param {type:\"slider\", min:1, max:50, step:1}\n",
    "\n",
    "s_apply_group(GROUP, globals())\n",
    "\n",
    "def my_features(df):\n",
    "    # df：每天一列，只有「今天收盤就知道」的欄位：Date、Open、High、Low、Close、Volume、MA5、MA10、MA20、MA60、\n",
    "    # MA_T（B2 設定的均線）、BIAS_T、K9、D9…。回傳一張全是數字的表，一欄一個特徵。\n",
    "    F = pd.DataFrame(index=df.index)\n",
    "    c = df[\"Close\"]\n",
    "    if BIAS_15: F[f\"BIAS{MA_DAYS}\"] = df[\"BIAS_T\"]                                       # (收盤 ÷ 15 日均線 − 1) × 100\n",
    "    if BIAS_20: F[\"BIAS20\"] = (c / c.rolling(20).mean() - 1) * 100\n",
    "    if BIAS_5: F[\"BIAS5\"] = (c / c.rolling(5).mean() - 1) * 100\n",
    "    if BIAS_8: F[\"BIAS8\"] = (c / c.rolling(8).mean() - 1) * 100\n",
    "    if MA15_slope: F[f\"MA{MA_DAYS}斜率\"] = (df[\"MA_T\"] / df[\"MA_T\"].shift(5) - 1) * 100       # 均線 5 天漲跌（%）\n",
    "    if MA5_over_MA20: F[\"MA5/MA20\"] = df[\"MA5\"] / df[\"MA20\"]                                  # 均線比：短均線在長均線上面（>1）還是下面\n",
    "    if MA10_over_MA20: F[\"MA10/MA20\"] = df[\"MA10\"] / df[\"MA20\"]\n",
    "    if MA20_over_MA60: F[\"MA20/MA60\"] = df[\"MA20\"] / df[\"MA60\"]\n",
    "    if Return_1d: F[\"報酬率1日\"] = c.pct_change() * 100                                      # 今天漲跌幅（%）\n",
    "    if Return_5d: F[\"報酬率5日\"] = c.pct_change(5) * 100                                     # 近 5 天漲跌幅（%）\n",
    "    if ROC_10: F[\"ROC10\"] = c.pct_change(10) * 100                                     # 10 日變動率\n",
    "    if Gap_open: F[\"跳空缺口\"] = (df[\"Open\"] / c.shift(1) - 1) * 100                          # 今天開盤相對昨天收盤（%）\n",
    "    if Intraday_change: F[\"日內漲跌\"] = (c / df[\"Open\"] - 1) * 100                             # 今天收盤相對開盤（%）\n",
    "    if MACD_hist:                                                                         # MACD（12, 26, 9）柱狀體，除以收盤價變成 %\n",
    "        dif = c.ewm(span=12, adjust=False).mean() - c.ewm(span=26, adjust=False).mean()     # DIF＝快線 − 慢線\n",
    "        F[\"MACD柱\"] = (dif - dif.ewm(span=9, adjust=False).mean()) / c * 100                # 柱＝DIF − 訊號線（DEA）\n",
    "    if KD_K: F[\"K9\"] = df[\"K9\"]                                                           # KD 隨機指標（9 日）的 K\n",
    "    if KD_D: F[\"D9\"] = df[\"D9\"]                                                           # KD 隨機指標（9 日）的 D\n",
    "    if RSI:                                                                     # RSI＝100 × 平均漲幅 ÷（平均漲幅＋平均跌幅）\n",
    "        diff = c.diff(); up = diff.clip(lower=0).rolling(RSI_days).mean(); down = (-diff.clip(upper=0)).rolling(RSI_days).mean()\n",
    "        F[f\"RSI{RSI_days}\"] = (100 * up / (up + down)).fillna(50)\n",
    "    if Williams_R:                                                                       # 威廉指標 %R：收盤價在最近 N 天高低區間的位置（−100～0）\n",
    "        hh, ll = df[\"High\"].rolling(Williams_days).max(), df[\"Low\"].rolling(Williams_days).min()\n",
    "        F[f\"WMS%R{Williams_days}\"] = ((hh - c) / (hh - ll) * -100).fillna(-50)\n",
    "    if Bollinger_B:                                                                     # 布林通道 %B（20 日、2 倍標準差）：0＝下軌、1＝上軌\n",
    "        mid, sd = c.rolling(20).mean(), c.rolling(20).std()\n",
    "        F[\"BB%B\"] = (c - (mid - 2 * sd)) / (4 * sd)\n",
    "    if Range_day: F[\"震幅\"] = (df[\"High\"] - df[\"Low\"]) / c * 100                            # (最高 − 最低) ÷ 收盤（%）\n",
    "    if Volatility_5d: F[\"波動率5日\"] = c.pct_change().rolling(5).std() * 100                     # 近 5 日報酬的標準差\n",
    "    if Volume_ratio: F[\"量比\"] = df[\"Volume\"] / df[\"Volume\"].rolling(20).mean()              # 成交量是 20 日均量的幾倍\n",
    "    # ✏️ 想加勾選裡沒有的特徵，寫在這裡，例如：\n",
    "    # F[\"高點距離\"] = (c / df[\"High\"].rolling(60).max() - 1) * 100       # 離 60 日最高點還差幾 %\n",
    "    # 外部資料（外資、比特幣…）：準備有 Date 欄的 CSV，收盤後才公布的要 shift(1)\n",
    "    # extra = pd.read_csv(\"my.csv\", parse_dates=[\"Date\"])\n",
    "    # F[\"x\"] = df[[\"Date\"]].merge(extra, on=\"Date\", how=\"left\")[\"x\"].shift(1).values\n",
    "    return F\n",
    "\n",
    "s_play(my_features, note=NOTE, model=MODEL, scale=SCALE, ridge_alpha=RIDGE_ALPHA, svr_C=SVR_C, svr_epsilon=SVR_EPSILON, svr_gamma=SVR_GAMMA,\n",
    "       hidden_layers=HIDDEN_LAYERS, neurons=NEURONS, activation=ACTIVATION, loss=LOSS, optimizer=OPTIMIZER, lr=LR, momentum=MOMENTUM,\n",
    "       batch=BATCH, epochs=EPOCHS, l2=L2, dropout=DROPOUT, early_stop=EARLY_STOP, patience=PATIENCE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6333441c",
   "metadata": {},
   "source": [
    "### B4-2｜挑戰提示：一次只改一個地方\n",
    "\n",
    "| 試試看 | 改哪裡 | 講義 | 想一想 |\n",
    "|---|---|---|---|\n",
    "| 1 學習率 | `LR`：0.0001 → 0.01 → 1.0 | P20 | 太小和太大，MAE 曲線各長什麼樣？ |\n",
    "| 2 Step 與 Batch | `EPOCHS` 20 → 100；`BATCH` 1、32、173 | P19 | steps 差幾倍？ |\n",
    "| 3 最佳化器 | `OPTIMIZER`：SGD → Momentum → Adam | P52 | 同樣 20 個 epoch，誰先學會？ |\n",
    "| 4 損失函數 | `LOSS`：MSE → MAE | P140 | 遇到大漲大跌時，誰比較被牽著走？ |\n",
    "| 5 線性 vs 神經網路 | `MODEL` Ridge vs NN；`HIDDEN_LAYERS` 0 vs 1 | P48、P145 | 這個題目需要彎曲的邊界嗎？ |\n",
    "| 6 特徵組 | `MODEL`=Ridge，`GROUP` A → B → C → D | P144、P135 | 指標越多越好嗎？ |\n",
    "| 7 自己挑指標 | `GROUP`=自己勾，試 `Return_5d`、`BIAS_8`、`KD_K`、`RSI`、`Williams_R`、`MACD_hist`…，最後全部勾 | P136、P148 | 哪一個最有用？全勾會怎樣？ |\n",
    "| 8 製造過擬合，再救回來 | `GROUP`=D、`NEURONS`=64、`HIDDEN_LAYERS`=2、`OPTIMIZER`=Adam、`EPOCHS`=200；再分別開 `L2`=0.01、`DROPOUT`=0.5、`EARLY_STOP` | P78、P82–P84 | 訓練只有 173 天，參數有幾千個，會發生什麼事？ |\n",
    "| 9 Ridge 的 L2 | `MODEL`=Ridge、`GROUP`=D，`RIDGE_ALPHA` 0 → 100 | P82 | α 太大會怎樣？ |\n",
    "| 10 SVR | `MODEL`=SVR，`SVR_C` 0.1 → 100、`SVR_EPSILON`、`SVR_GAMMA` | P128 | 和 Ridge 比誰贏？ |\n",
    "| 11 換題目 | 回 B2 改 `MA_DAYS`、`HORIZON`（例如 10／3、20／10） | P147 | 已知的天數越多越容易嗎？（不計牌） |\n",
    "\n",
    "**想一想**：答案要比的是「7 天後的 15 日均線」，這 15 天裡有幾天的收盤價今天就已經知道？\n",
    "驗證期只有 235 天，反覆挑最高分也會慢慢「背」驗證期（P85），所以最後要用 2025 年驗收。"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f1900749",
   "metadata": {},
   "source": [
    "### 🎛️ 把幾個回合的預測疊在一起比較\n",
    "\n",
    "填回合編號（排行榜第一欄），-1＝目前第一名。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "564e25bc",
   "metadata": {},
   "outputs": [],
   "source": [
    "COMPARE = [0, -1]   # 🎛️ 改這裡\n",
    "s_compare(COMPARE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "493421fd",
   "metadata": {},
   "source": [
    "### 完整排行榜"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "88de19d4",
   "metadata": {},
   "outputs": [],
   "source": [
    "s_board()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3e8e7cf1",
   "metadata": {},
   "source": [
    "## B6｜🎛️ 選做：進場之後，什麼時候賣？（P151）\n",
    "\n",
    "**進場都一樣**：收盤後模型預測「7 天後的均線比今天收盤價高」（趨勢往上），隔天開盤買。**差別只在出場**：\n",
    "\n",
    "| `EXIT` | 什麼時候賣 |\n",
    "|---|---|\n",
    "| 隔天收盤賣 | 買進當天收盤就賣（原本的示範） |\n",
    "| 持有 N 天 | 抱 `HOLD_DAYS` 天後收盤賣（預設 7 天，和預測的天數一樣） |\n",
    "| 停利停損 | 漲到 `TAKE_PROFIT`% 就賣（停利）、跌到 `STOP_LOSS`% 就賣（停損）；都沒碰到，抱滿 `MAX_DAYS` 天 |\n",
    "| 移動停損 | 從買進後的最高收盤價回落 `TRAIL`% 就賣：讓獲利奔跑、回頭才走 |\n",
    "| 訊號反轉才賣 | 模型改說「往下」，隔天開盤賣 |\n",
    "\n",
    "`COMPARE_ALL` 打勾會把 5 種出場的**累積獲利**（帳戶金額，從 `CAPITAL` 元開始）畫在同一張圖上比較，並列出交易數、勝率、平均獲利／虧損、**期望值**、總報酬、最大回撤。\n",
    "期望值＝勝率 × 平均獲利 ＋ 敗率 × 平均虧損（P151）：勝率高不代表賺錢，賺的時候賺多少、賠的時候賠多少一樣重要。\n",
    "\n",
    "只在 2024 驗證期示範（開測試集前）。成本、可分割部位都是教學假設；出場參數也不要一直調到最好看，那會和反覆挑最高分一樣「背」住驗證期。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c0e7e643",
   "metadata": {},
   "outputs": [],
   "source": [
    "#@title 🎛️ 出場策略\n",
    "RUN_TRADE_DEMO = True  #@param {type:\"boolean\"}\n",
    "TRADE_RUN = -1  #@param {type:\"integer\"}\n",
    "#@markdown TRADE_RUN：用哪個回合的訊號，-1＝排行榜第一名\n",
    "EXIT = '持有 N 天'  #@param ['隔天收盤賣', '持有 N 天', '停利停損', '移動停損', '訊號反轉才賣']\n",
    "HOLD_DAYS = 7  #@param {type:\"slider\", min:1, max:20, step:1}\n",
    "TAKE_PROFIT = 5  #@param {type:\"slider\", min:1, max:20, step:1}\n",
    "STOP_LOSS = 3  #@param {type:\"slider\", min:1, max:15, step:1}\n",
    "TRAIL = 5  #@param {type:\"slider\", min:1, max:15, step:1}\n",
    "MAX_DAYS = 20  #@param {type:\"slider\", min:1, max:60, step:1}\n",
    "#@markdown TAKE_PROFIT、STOP_LOSS、TRAIL 單位是 %；MAX_DAYS：停利停損、移動停損、訊號反轉最多抱幾天\n",
    "ALLOCATION = 0.5  #@param {type:\"slider\", min:0.1, max:1, step:0.1}\n",
    "COST_BPS = 40  #@param {type:\"slider\", min:0, max:100, step:5}\n",
    "CAPITAL = 100000  #@param {type:\"integer\"}\n",
    "#@markdown ALLOCATION：每筆投入資金比例；COST_BPS：一筆來回的成本（含滑價），1 bp＝0.01%；CAPITAL：起始資金（元）\n",
    "COMPARE_ALL = True  #@param {type:\"boolean\"}\n",
    "if RUN_TRADE_DEMO:\n",
    "    trade_table = stock_trade_demo(TRADE_RUN, EXIT, HOLD_DAYS, TAKE_PROFIT, STOP_LOSS, TRAIL, MAX_DAYS, ALLOCATION, COST_BPS, COMPARE_ALL, CAPITAL)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d45545e5",
   "metadata": {},
   "source": [
    "## B7｜最後測試與 Class 3 交接（P155、P156）\n",
    "\n",
    "選定回合（-1＝排行榜第一名），把 `CONFIRM` 改成 True。2025 年只看一次，看完不再回頭調參。\n",
    "\n",
    "會看到：①2025 預測曲線與誤差比、方向對照表；②**2025 累積獲利**：用你在 B6 定案的出場方式（粗線），和一直持有、其他出場方式（細線，只供對照）比較。出場方式要在 B6 先定案，不能看完 2025 的結果再換。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5e0bf084",
   "metadata": {},
   "outputs": [],
   "source": [
    "SELECTED_RUN = -1        # 🎛️ 回合編號\n",
    "CONFIRM = False\n",
    "s_finish(SELECTED_RUN, confirm=CONFIRM)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ca4bcc30",
   "metadata": {},
   "outputs": [],
   "source": [
    "EXPORT_FOR_CLASS3 = False   # 🎛️ 最後測試做完後改成 True\n",
    "if EXPORT_FOR_CLASS3:\n",
    "    export_handoff()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "58df5908",
   "metadata": {},
   "source": [
    "## B8｜反思與繳交\n",
    "\n",
    "回答三個問題（用自己的話，引用你在 B4、B6、B7 看到的數字），保存圖和排行榜，繳交可檢視的 Colab 連結；Class 3 交接檔從左側檔案區下載保存。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3a45fd93",
   "metadata": {},
   "outputs": [],
   "source": [
    "q1_hard_to_beat = \"請填寫\"   # ① 為什麼這段時間很難打贏大盤（預測 vs 一直持有）？\n",
    "q2_improve = \"請填寫\"        # ② 你認為要怎麼提升預測準確率？\n",
    "q3_accuracy_vs_profit = \"請填寫\"   # ③ 準確率高就一定能獲利嗎？\n",
    "print('①', q1_hard_to_beat, '\\n②', q2_improve, '\\n③', q3_accuracy_vs_profit)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "faa449c7",
   "metadata": {},
   "source": [
    "## 資料與閱讀\n",
    "\n",
    "- TWSE STOCK_DAY：https://www.twse.com.tw/exchangeReport/STOCK_DAY?response=json&date=20250101&stockNo=2330\n",
    "- 各月來源與雜湊：`05_資料快照/manifest.json`\n",
    "- https://scikit-learn.org/stable/common_pitfalls.html"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
