"""台股大盤擇時 13 個訊號的三項驗證（/blog/taiwan-market-timing-signals-backtest 的數字來源）。

同一份程式也產生景氣燈號、M1B、庫藏股、騰落指標 ADL 四篇舊文的鄰近參數驗證，以及國安基金、
庫藏股兩個通過策略的對照檢查（拿掉最好一年、2018 年後、合成正2 推回 2007、固定比例正2）。

規則：
- 單獨版：訊號開持有 0050、關持有現金；組合版：訊號開持有 00631L、關持有 0050
- 訊號日的下一個交易日收盤成交，sim() 預設手續費 0.1425%、證交稅 0.3%；另跑 ETF 稅率 0.1% 與零成本對照
- 「贏」＝日夏普（日報酬平均 ÷ 標準差 × √252，不扣無風險利率）高於同期 0050 含息買進持有（etl:adj_close，不扣成本）
- 單獨版從 9 組參數都有訊號、0050 還原價有值的第一天起算；組合版從 00631L 上市（2014-10-31）起算；前後兩段＝日曆中點切開
- 資料截到 END（2026-10-30），要跑到最新資料就把 END 改掉

執行：

    pip install finlab plotly
    python research.py                 # 全部設計＋對照檢查，約 15 分鐘
    python research.py s7_nsf s3_pmi   # 只跑指定的訊號

輸出：results/<設計>.json（每組參數的統計）、results/extras.json（對照檢查）、
metrics.json（全部結果彙整）、data.csv（各設計原參數的每日淨值）。
finlab 需要資料時會自動引導登入，不需要在程式裡填 token。
"""
import functools
import itertools
import json
import os
import sys

import numpy as np
import pandas as pd

import finlab
from finlab import data
from finlab.backtest import sim

finlab.login()

END = '2026-10-30'
COMBO_START = pd.Timestamp('2014-10-31')  # 00631L 上市
OUT = 'results'

adj = data.get('etl:adj_close').loc[:END]
DATES = adj.index
bench_px = adj['0050'].ffill()  # 0050 2025-06 分割停牌，先補價再算報酬
lev_px = adj['00631L'].ffill()


def to_daily(s):
    """把以「可取得日」為 index 的序列展開到每個交易日（當天起可用）。"""
    s = s[~s.index.duplicated(keep='last')].sort_index()
    return s.reindex(DATES.union(s.index)).ffill().reindex(DATES)


def hold_after(event_dates, offset, length):
    """事件日（取第一個 >= 事件日的交易日）後第 offset 個交易日起，連續 length 個交易日為 True。"""
    out = np.zeros(len(DATES), dtype=bool)
    for d in event_dates:
        if pd.Timestamp(d) < DATES[0]:
            continue
        i = DATES.searchsorted(pd.Timestamp(d))
        out[i + offset:i + offset + length] = True
    return pd.Series(out, index=DATES)


def sharpe(r):
    r = r.dropna()
    return float(r.mean() / r.std() * np.sqrt(252)) if r.std() > 0 else float('nan')


def stats(cret):
    r = cret.pct_change().iloc[1:]
    years = (cret.index[-1] - cret.index[0]).days / 365.25
    return {
        'sharpe': sharpe(r),
        'cagr': float((cret.iloc[-1] / cret.iloc[0]) ** (1 / years) - 1),
        'mdd': float((cret / cret.cummax() - 1).min()),
    }


# ---------- 訊號 ----------

def hysteresis(on, off):
    """on 成立轉開、off 成立轉關，其餘維持前一狀態。"""
    state, out = np.nan, []
    for a, b in zip(on, off):
        if a:
            state = 1.0
        elif b:
            state = 0.0
        out.append(state)
    return pd.Series(out, index=on.index)


def business_score():
    v = data.get('tw_business_indicators:景氣對策信號(分)').iloc[:, 0].dropna()
    return v[v > 0]


def business_signal(L, H):
    v = business_score()
    return to_daily(hysteresis(v <= L, v >= H))


def leading_signal(U, D):
    v = data.get('tw_business_indicators:領先指標不含趨勢指數(點)').iloc[:, 0].dropna()
    d = v.diff()
    up = (d > 0).astype(int).groupby((d <= 0).cumsum()).cumsum()
    down = (d < 0).astype(int).groupby((d >= 0).cumsum()).cumsum()
    return to_daily(hysteresis(up >= U, down >= D))


def pmi():
    return data.get('tw_total_pmi:製造業PMI').iloc[:, 0].dropna()


def pmi_signal(T, k):
    v = pmi()
    sig = ((v >= T) & (v > v.shift(k))).astype(float)
    sig[v.shift(k).isna()] = np.nan
    return to_daily(sig)


def money():
    return data.get('tw_monetary_aggregates:年增率(%)').dropna()


def m1b_m2_signal(theta, m):
    df = money()
    spread = (df['M1B'] - df['M2']).rolling(m).mean()
    return to_daily((spread > theta).astype(float).where(spread.notna()))


def m1b_ma_signal(n, theta):
    v = money()['M1B']
    ma = v.rolling(n, min_periods=n // 2).mean()
    return to_daily((v > ma + theta).astype(float).where(ma.notna()))


def export_signal(theta, k):
    v = data.get('tw_customs_trade_statistics:出口總值(新臺幣千元)').iloc[:, 0].dropna().astype(float)
    s3 = v.rolling(3).sum()
    yoy = s3 / s3.shift(12) - 1
    sig = ((yoy >= theta) & (yoy > yoy.shift(k))).astype(float).where(yoy.shift(k).notna())
    sig.index = sig.index + pd.Timedelta(days=15)  # 統計月月底 → 約次月中可取得
    return to_daily(sig)


def nsf_signal(H, d):
    nsf = data.get('national_security_fund')
    return hold_after(nsf['啟動時間'], d, H).astype(float)


def treasury_months(k):
    """每月宣布買回的公司家數，與門檻「截至當月平均 + k 個標準差」（至少 24 個月資料）。"""
    ts = data.get('treasury_stock:預定買回股數')
    month = ts.notna().groupby(ts.index.to_period('M')).any().sum(axis=1)
    month = month.reindex(pd.period_range(month.index[0], month.index[-1], freq='M'), fill_value=0)
    line = month.expanding(24).mean() + k * month.expanding(24).std(ddof=0)
    return month, line


def treasury_signal(k, H):
    month, line = treasury_months(k)
    ends = [p.to_timestamp(how='end').normalize() for p in month[month > line].index]
    ends = [d for d in ends if d <= DATES[-1]]
    sig = hold_after(ends, 0, H).astype(float)
    sig[DATES < line.first_valid_index().to_timestamp(how='end')] = np.nan
    return sig


def common_stocks():
    return [c for c in adj.columns if len(c) == 4 and c.isdigit() and c[0] != '0']


@functools.cache
def adl_line():
    px = adj[common_stocks()]
    ratio = px / px.shift()
    return ((ratio > 1).sum(axis=1) - (ratio < 1).sum(axis=1)).iloc[1:].cumsum()


def adl_signal(s, l):
    a = adl_line()
    fast, slow = a.rolling(s).mean(), a.rolling(l).mean()
    return (fast > slow).astype(float).where(slow.notna()).reindex(DATES)


def foreign_oi():
    return data.get('futures_institutional_investors_trading_summary:多空未平倉口數淨額')['臺股期貨_外資及陸資'].dropna()


def foreign_oi_signal(N, theta):
    f = foreign_oi()
    ma = f.rolling(N).mean()
    return to_daily((f >= ma + theta).astype(float).where(ma.notna()))


def large_trader_ratio():
    lt = data.get('tw_taifex_futures_large_trader')
    lt = lt[lt['symbol'].str.startswith('臺股期貨') & (lt['到期月份(週別)'] == '所有契約')]
    lt = lt.set_index('date').sort_index()
    return (lt['買方前十大特定法人部位數'] - lt['賣方前十大特定法人部位數']) / lt['全市場未沖銷部位數']


def large_trader_signal(n, theta):
    r = large_trader_ratio().rolling(n).mean()
    return to_daily((r >= theta).astype(float).where(r.notna()))


def third_wednesday(ym):
    first = pd.Timestamp(f'{ym[:4]}-{ym[4:6]}-01')
    return first + pd.Timedelta(days=(2 - first.weekday()) % 7 + 14)


@functools.cache
def basis(adjusted):
    """近月台指期基差 ÷ 加權指數；adjusted=True 時加回到期前會除息的點數。"""
    fut = data.get('futures_price:收盤價')['TX一般']
    ym = data.get('futures_price:到期月份(週別)')['TX一般']
    spot = data.get('taiex_total_index:收盤指數')['TAIEX']
    tr = data.get('benchmark_return:發行量加權股價報酬指數').iloc[:, 0]
    df = pd.concat({'f': fut, 'ym': ym, 's': spot, 'tr': tr}, axis=1).dropna()
    b = df['f'] - df['s']
    if adjusted:
        div = (df['s'].shift() * df['tr'] / df['tr'].shift() - df['s']).clip(lower=0).fillna(0)
        cum = div.cumsum()
        expiry = df['ym'].str[:6].map(third_wednesday)
        pos = cum.index.searchsorted(expiry, side='right') - 1
        b = b + (cum.values[pos] - cum.values).clip(min=0)
    return b / df['s']


def basis_signal(theta, n, adjusted=True):
    b = basis(adjusted).rolling(n).mean()
    return to_daily((b >= -theta).astype(float).where(b.notna()))


def us_drop_events(x):
    nasdaq = data.get('world_index:close')['^IXIC'].dropna().pct_change()
    soxx = data.get('us_fund_price:adj_close')['SOXX'].dropna().pct_change()
    days = nasdaq.index.union(soxx.index)
    hit = (nasdaq <= -x).reindex(days, fill_value=False) | (soxx <= -x).reindex(days, fill_value=False)
    return hit[hit].index, max(nasdaq.index[1], soxx.index[1])


def us_drop_signal(x, H):
    events, start = us_drop_events(x)
    sig = (~hold_after(events, 0, H)).astype(float)
    sig[DATES < start] = np.nan
    return sig


# ---------- 舊文規則 ----------

def batch_weight(v, L, H, W, B):
    """景氣燈號加減碼原文：近 W 月每有一月 ≤L 分加碼 1/B，≥H 分全出。"""
    sig = pd.Series(np.where(v <= L, 1.0, np.where(v >= H, 0.0, np.nan)), index=v.index)
    w = ((sig / B).rolling(W, min_periods=1).sum() * sig).ffill().clip(0, 1)
    return to_daily(w).to_frame('0050')


def business_batch_position(L, H, W, B):
    return batch_weight(business_score(), L, H, W, B)


def adl_stock_position(s, l):
    """ADL 原文：多方等權持有上市櫃普通股（換段時定股票池）、空方持有 0050。"""
    sig = adl_signal(s, l)
    stocks = common_stocks()
    px = adj[stocks]
    seg = sig.ne(sig.shift()).cumsum()
    rows = []
    for _, block in sig.groupby(seg):
        if block.isna().all():
            continue
        if block.iloc[0] == 1:
            live = px.loc[block.index[0]].notna()
            w = live / live.sum()
            rows.append(pd.DataFrame([w.values] * len(block), index=block.index, columns=stocks).assign(**{'0050': 0.0}))
        else:
            rows.append(pd.DataFrame(0.0, index=block.index, columns=stocks).assign(**{'0050': 1.0}))
    return pd.concat(rows).sort_index()


# ---------- 回測 ----------

def run(position, start, **costs):
    return sim(position.loc[start:END], upload=False, **costs).creturn.loc[start:END]


def evaluate(cret, start):
    mid = start + (pd.Timestamp(END) - start) / 2
    b = bench_px.loc[start:END]
    out = {'start': str(start.date()), 'mid': str(mid.date()), 'end': END}
    out['strategy'] = stats(cret)
    out['bench'] = stats(b)
    r, rb = cret.pct_change(), b.pct_change()
    out['halves'] = [
        {'strategy': sharpe(r.loc[:mid].iloc[1:]), 'bench': sharpe(rb.loc[:mid].iloc[1:])},
        {'strategy': sharpe(r.loc[mid:].iloc[1:]), 'bench': sharpe(rb.loc[mid:].iloc[1:])},
    ]
    out['win'] = out['strategy']['sharpe'] > out['bench']['sharpe']
    return out


def position_from(sig, mode):
    sig = sig.reindex(DATES)
    if mode == 'solo':
        return pd.DataFrame({'0050': sig})
    return pd.DataFrame({'00631L': sig, '0050': 1 - sig})


SIGNALS = {
    's1_business': (business_signal, {'L': [14, 16, 18], 'H': [36, 38, 40]}, {'L': 16, 'H': 38}),
    's2_leading': (leading_signal, {'U': [2, 3, 4], 'D': [2, 3, 4]}, {'U': 3, 'D': 3}),
    's3_pmi': (pmi_signal, {'T': [48, 50, 52], 'k': [2, 3, 4]}, {'T': 50, 'k': 3}),
    's4_m1b_m2': (m1b_m2_signal, {'theta': [-0.5, 0, 0.5], 'm': [1, 2, 3]}, {'theta': 0, 'm': 1}),
    's5_m1b_ma': (m1b_ma_signal, {'n': [4, 6, 8], 'theta': [-0.5, 0, 0.5]}, {'n': 6, 'theta': 0}),
    's6_export': (export_signal, {'theta': [-0.05, 0, 0.05], 'k': [2, 3, 4]}, {'theta': 0, 'k': 3}),
    's7_nsf': (nsf_signal, {'H': [125, 250, 375], 'd': [0, 10, 20]}, {'H': 250, 'd': 0}),
    's8_treasury': (treasury_signal, {'k': [1.0, 1.5, 2.0], 'H': [125, 250, 375]}, {'k': 1.5, 'H': 250}),
    's9_adl': (adl_signal, {'s': [3, 5, 7], 'l': [40, 60, 80]}, {'s': 5, 'l': 60}),
    's10_foreign_oi': (foreign_oi_signal, {'N': [10, 20, 40], 'theta': [-2000, 0, 2000]}, {'N': 20, 'theta': 0}),
    's11_large_trader': (large_trader_signal, {'n': [3, 5, 10], 'theta': [-0.05, 0, 0.05]}, {'n': 5, 'theta': 0}),
    's12_basis': (basis_signal, {'theta': [0.0025, 0.005, 0.0075], 'n': [3, 5, 10]}, {'theta': 0.005, 'n': 5}),
    's13_us_drop': (us_drop_signal, {'x': [0.02, 0.03, 0.04], 'H': [3, 5, 10]}, {'x': 0.03, 'H': 5}),
}

OLD = {
    'o1_business_batch': (business_batch_position,
                          {'L': [16, 18, 20], 'H': [38, 40, 42], 'W': [9, 12, 15], 'B': [4, 5, 6]},
                          {'L': 18, 'H': 40, 'W': 12, 'B': 5}),
    'o4_adl_stocks': (adl_stock_position, {'s': [3, 5, 7], 'l': [40, 60, 80]}, {'s': 5, 'l': 60}),
}



def base_signal(key):
    """原參數的訊號（舊文規則回傳部位）。"""
    fn, _, params = {**SIGNALS, **OLD}[key]
    return fn(**params)


COSTS = {'default': {}, 'etf_tax': {'tax_ratio': 0.001}, 'no_cost': {'fee_ratio': 0, 'tax_ratio': 0}}


def grid(params):
    keys = list(params)
    return [dict(zip(keys, v)) for v in itertools.product(*params.values())]


def first_valid(frames):
    return max(f.dropna(how='all').index[0] for f in frames)


def exposure(pos, mode):
    """組合版＝平均正 2 比重；單獨版＝平均 0050 比重。"""
    held = pos.drop(columns='0050').sum(axis=1) if mode == 'combo' else pos['0050']
    return float(held.mean())


def study(key, positions, base, mode):
    """positions: {參數字串: 部位表}；回傳全格結果、原參數的前後兩段與成本對照。"""
    floor = COMBO_START if mode == 'combo' else adj['0050'].first_valid_index()
    start = max(first_valid(list(positions.values())), floor)
    res = {'start': str(start.date()), 'grid': {}}
    curves = {}
    for name, pos in positions.items():
        curves[name] = run(pos, start)
        res['grid'][name] = evaluate(curves[name], start)
        pos_in = pos.loc[start:END]
        res['grid'][name]['exposure'] = exposure(pos_in, mode)
        res['grid'][name]['switches'] = int((pos_in.diff().abs().sum(axis=1) > 0).sum())
    res['base'] = base
    res['costs'] = {}
    for c, kw in COSTS.items():
        cret = run(positions[base], start, **kw) if kw else curves[base]  # 預設成本已在上面跑過
        res['costs'][c] = evaluate(cret, start)
        if c == 'default':
            (cret / cret.iloc[0]).rename(key).to_csv(f'{OUT}/{key}.csv')
    res['wins'] = sum(g['win'] for g in res['grid'].values())
    res['n'] = len(res['grid'])
    o = res['costs']['default']
    res['pass'] = {
        'neighbors': res['wins'] >= 0.7 * res['n'],
        'halves': all(h['strategy'] > h['bench'] for h in o['halves']),
        'costs': o['win'],
    }
    return res


def label(p):
    return ','.join(f'{k}={v}' for k, v in p.items())


def main(keys):
    os.makedirs(OUT, exist_ok=True)
    for key in keys:
        if key in SIGNALS:
            fn, params, base = SIGNALS[key]
            sigs = {label(p): fn(**p) for p in grid(params)}
            for mode in ['solo', 'combo']:
                pos = {k: position_from(s, mode) for k, s in sigs.items()}
                res = study(f'{key}_{mode}', pos, label(base), mode)
                if key == 's12_basis':
                    start = pd.Timestamp(res['start'])
                    raw = position_from(basis_signal(adjusted=False, **base), mode)
                    res['raw_basis'] = evaluate(run(raw, start), start)
                save(f'{key}_{mode}', res)
        elif key == 'extras':
            extras()
        else:
            fn, params, base = OLD[key]
            pos = {label(p): fn(**p) for p in grid(params)}
            save(key, study(key + '_solo', pos, label(base), 'solo'))


def save(name, res):
    with open(f'{OUT}/{name}.json', 'w') as f:
        json.dump(res, f, ensure_ascii=False, indent=1, default=float)
    p = res['pass']
    print(f"{name}: start {res['start']} wins {res['wins']}/{res['n']} pass {p}")


def drop_best_year(r, rb):
    """拿掉策略相對 0050 超額報酬最大的那一年後的日夏普。"""
    excess = (1 + r).groupby(r.index.year).prod() - (1 + rb).groupby(rb.index.year).prod()
    keep = r.index.year != excess.idxmax()
    return {'best_year': int(excess.idxmax()), 'strategy': sharpe(r[keep]), 'bench': sharpe(rb[keep])}


def extras():
    """通過或數字過關的設計另做的檢查：拿掉最好一年、2018 年後、景氣分數擾動、推回 2007 的合成正 2、事件表。"""
    out = {}
    br = bench_px.pct_change()
    s0, c0 = pd.Timestamp('2007-04-23'), COMBO_START
    lead, nsf, trs = base_signal('s2_leading'), base_signal('s7_nsf'), base_signal('s8_treasury')
    cases = {
        'o1_business_batch': (base_signal('o1_business_batch'), s0),
        's2_leading_solo': (position_from(lead, 'solo'), s0),
        's2_leading_combo': (position_from(lead, 'combo'), c0),
        's7_nsf_combo': (position_from(nsf, 'combo'), c0),
        's8_treasury_combo': (position_from(trs, 'combo'), c0),
    }
    for k, (pos, st) in cases.items():
        r = run(pos, st).pct_change().iloc[1:]
        rb = br.loc[r.index]
        yearly = ((1 + r).groupby(r.index.year).prod() - 1) - ((1 + rb).groupby(rb.index.year).prod() - 1)
        out[k] = {'drop_best_year': drop_best_year(r, rb),
                  'since_2018_03': {'strategy': sharpe(r.loc['2018-03-01':]), 'bench': sharpe(rb.loc['2018-03-01':])},
                  'yearly_excess': {int(y): float(v) for y, v in yearly.items()}}

    # 景氣燈號加減碼：公布日延後 5 個交易日、每月分數隨機加減 1 或 2 分（各 100 次）
    v0 = business_score()
    o1 = OLD['o1_business_batch'][2]
    e = evaluate(run(batch_weight(v0, **o1).shift(5), s0), s0)
    out['o1_release_lag5'] = {'strategy': e['strategy']['sharpe'], 'bench': e['bench']['sharpe'], 'halves': e['halves']}
    rng = np.random.default_rng(253)
    for noise in [1, 2]:
        full = every = 0
        for _ in range(100):
            e = evaluate(run(batch_weight(v0 + rng.integers(-noise, noise + 1, len(v0)), **o1), s0), s0)
            full += e['win']
            every += e['win'] and all(h['strategy'] > h['bench'] for h in e['halves'])
        out[f'o1_noise_{noise}'] = {'full_period_wins': full, 'all_three_wins': every, 'draws': 100}

    # 00631L 上市前沒有正 2：用 0050 日報酬 ×2、每年扣 1% 費用，從 2007 推回去看金融海嘯那一次
    r50 = br.loc['2007-04-24':END]
    lev = 2 * r50 - 0.01 / 252
    for k, sig in [('s7_nsf', nsf), ('s8_treasury', trs)]:
        held = sig.reindex(r50.index).shift(2).fillna(0)  # 訊號日 → 隔日收盤成交 → 再隔日起算報酬
        r = held * lev + (1 - held) * r50
        mid = r.index[0] + (r.index[-1] - r.index[0]) / 2
        out[f'{k}_synthetic_since_2007'] = {
            'strategy': sharpe(r), 'bench': sharpe(r50),
            'halves': [{'strategy': sharpe(r[:mid]), 'bench': sharpe(r50[:mid])},
                       {'strategy': sharpe(r[mid:]), 'bench': sharpe(r50[mid:])}]}

    # 2008 年國安基金進場（09-19 收盤買）後：合成正2 與 0050 的最低點、0050 持有 125 個交易日的報酬
    i = DATES.searchsorted(pd.Timestamp('2008-09-19'))
    seg = r50.loc[DATES[i + 1]:DATES[i + 250]]
    out['nsf_2008'] = {'synthetic_2x_drop_from_entry': float((1 + 2 * seg - 0.01 / 252).cumprod().min() - 1),
                       '0050_drop_from_entry': float((1 + seg).cumprod().min() - 1),
                       '0050_after_125d': float(bench_px.iloc[i + 125] / bench_px.iloc[i] - 1)}

    # 固定比例對照：整段都用同樣的平均正 2 比重（每天再平衡、不扣成本），看擇時有沒有比單純加槓桿好
    r631 = lev_px.pct_change().loc[COMBO_START:END].iloc[1:]
    for k in ['s7_nsf_combo', 's8_treasury_combo']:
        w = exposure(cases[k][0].loc[COMBO_START:END], 'combo')
        mix = w * r631 + (1 - w) * br.loc[r631.index]
        curve = (1 + mix).cumprod()
        out[k]['fixed_mix'] = {'weight_00631L': w, 'sharpe': sharpe(mix),
                               'cagr': float(curve.iloc[-1] ** (365.25 / (curve.index[-1] - curve.index[0]).days) - 1),
                               'mdd': float((curve / curve.cummax() - 1).min())}

    a, b = nsf.loc[COMBO_START:], trs.loc[COMBO_START:]
    out['overlap_nsf_treasury'] = {'nsf_days': int(a.sum()), 'treasury_days': int(b.sum()), 'both': int((a * b).sum())}

    # 國安基金歷次進場：金額、宣布退場，以及隔天收盤買進、持有一年（250 個交易日）的 0050、00631L 報酬（不扣成本）
    events = []
    for _, row in data.get('national_security_fund').iterrows():
        d = pd.Timestamp(row['啟動時間'])
        e = {'start': str(d.date()), 'exit_announced': str(pd.Timestamp(row['退場時間']).date()),
             'months_to_exit': round((pd.Timestamp(row['退場時間']) - d).days / 30.44, 1),
             'reason': row['時空背景'], 'amount': float(row['護盤金額(億)'])}
        if d >= DATES[0]:
            i = DATES.searchsorted(d) + 1
            j = min(i + 250, len(DATES) - 1)
            for name, px in [('0050', bench_px), ('00631L', lev_px)]:
                seg = px.iloc[i:j + 1]
                if not np.isnan(seg.iloc[0]):
                    e[f'r{name}'] = float(seg.iloc[-1] / seg.iloc[0] - 1)
                    e[f'mdd{name}'] = float((seg / seg.cummax() - 1).min())
                    e[f'drop_from_entry{name}'] = float(seg.min() / seg.iloc[0] - 1)
            e.update(entry=str(DATES[i].date()), exit=str(DATES[j].date()))
        events.append(e)
    out['nsf_events'] = events

    # 庫藏股家數過門檻的月份（k = 1.5）
    month, line = treasury_months(1.5)
    out['treasury_signal_months'] = {str(p): {'count': int(month[p]), 'threshold': float(line[p])}
                                     for p in month[month > line].index}
    # 外資台指期淨未平倉的年平均（口）
    f = foreign_oi().loc[:END]
    out['foreign_oi_yearly_mean'] = {int(y): float(v) for y, v in f.groupby(f.index.year).mean().items()}

    # 近月台指期基差的月份平均（÷ 加權指數），原始與加回到期前除息點數
    b = pd.DataFrame({'raw': basis(False), 'adjusted': basis(True)}).loc['2007-04-23':END]
    out['basis_monthly_mean'] = {int(m): {k: float(v) for k, v in g.mean().items()} for m, g in b.groupby(b.index.month)}

    # PMI 公布後 0050 的之後報酬：依「是否站上 50」「是否高於 3 個月前」分組（隔天收盤起算 60 個交易日）
    index = pmi()
    prev3 = index.shift(3)
    rows = []
    for d, v in index.items():
        i = DATES.searchsorted(d) + 1
        if i + 60 < len(DATES) and d >= DATES[0]:
            prev = prev3.get(d)
            rows.append({'above50': v >= 50, 'rising': bool(v > prev) if pd.notna(prev) else None,
                         'past60': float(bench_px.iloc[i - 1] / bench_px.iloc[max(i - 61, 0)] - 1),
                         'next60': float(bench_px.iloc[i + 60] / bench_px.iloc[i] - 1)})
    df = pd.DataFrame(rows)
    out['pmi_forward_60d'] = {f'above50={a}': {'n': int(len(g)), 'past60_mean': float(g['past60'].mean()), 'next60_mean': float(g['next60'].mean())}
                              for a, g in df.groupby('above50')}
    sig = df.dropna(subset=['rising'])
    sig = sig[sig['above50'] & sig['rising'].astype(bool)]
    out['pmi_forward_60d']['signal_on'] = {'n': int(len(sig)), 'past60_mean': float(sig['past60'].mean()), 'next60_mean': float(sig['next60'].mean())}

    # 基差訊號（扣除息、原參數）前段叫你空手的日子，0050 漲跌多少
    held = base_signal('s12_basis').loc['2007-04-23':'2017-01-25'].shift(2).fillna(1)
    r = br.loc[held.index]
    off = r[held == 0]
    out['basis_off_days_first_half'] = {'days': int(len(off)), 'bench_return': float((1 + off).prod() - 1),
                                        'by_year': {int(y): float((1 + g).prod() - 1) for y, g in off.groupby(off.index.year)}}

    # 美股前一晚大跌（費半或那斯達克跌 3% 以上）後，0050 當天與之後 5 天的報酬
    events, start = us_drop_events(0.03)
    idx = sorted({DATES.searchsorted(d) + 1 for d in events if d >= start})
    idx = [i for i in idx if i + 6 < len(DATES)]
    out['us_drop_reaction'] = {
        'events': len(idx),
        'reaction_day_mean': float(np.mean([br.iloc[i] for i in idx])),
        'reaction_day_down_share': float(np.mean([br.iloc[i] < 0 for i in idx])),
        'next5_mean': float(np.mean([bench_px.iloc[i + 5] / bench_px.iloc[i] - 1 for i in idx])),
        'all_days_5d_mean': float((bench_px.shift(-5) / bench_px - 1).loc['2016-01-05':END].mean()),
    }
    with open(f'{OUT}/extras.json', 'w') as f:
        json.dump(out, f, ensure_ascii=False, indent=1, default=float)
    print('extras done')


def summary():
    """results/*.json → metrics.json；各設計原參數的每日淨值 → data.csv。"""
    names = [f'{k}_{m}' for k in SIGNALS for m in ['solo', 'combo']] + list(OLD)
    metrics = {n: json.load(open(f'{OUT}/{n}.json')) for n in names}
    metrics['extras'] = json.load(open(f'{OUT}/extras.json'))
    metrics['run_date'] = str(pd.Timestamp.today().date())
    metrics['data_end'] = END
    metrics['finlab_version'] = finlab.__version__
    with open('metrics.json', 'w') as f:
        json.dump(metrics, f, ensure_ascii=False, indent=1, default=float)
    curves = pd.concat([pd.read_csv(f'{OUT}/{n}_solo.csv' if n in OLD else f'{OUT}/{n}.csv', index_col=0, parse_dates=True).iloc[:, 0]
                        for n in names], axis=1)
    curves['0050含息'] = bench_px / bench_px.loc[curves.index[0]]
    curves['00631L'] = lev_px / lev_px.loc[COMBO_START]
    curves.loc[:END].round(4).to_csv('data.csv', index_label='date')


if __name__ == '__main__':
    keys = sys.argv[1:] or list(SIGNALS) + list(OLD) + ['extras', 'summary']
    main([k for k in keys if k != 'summary'])
    if 'summary' in keys:
        summary()
