"""US calendar effects on QQQ and SPY (FinLab backtest).

Five calendar rules from the stock-market seasonality literature. Each rule holds
the ETF only on the trading days inside its window and sits in cash the rest of
the time (finlab's default: idle cash earns nothing):

  santa          Santa Claus rally: last 5 trading days of December + first 2 of January
  halloween      Sell in May / Halloween effect: hold November through April
  turn_of_month  Turn of the month: last trading day of a month + first 3 of the next
  pre_holiday    Pre-holiday effect: the trading day before each NYSE holiday
  may_june       "Sell in May" short version: cash in May and June, hold the other months

Every rule is judged against buying and holding QQQ with dividends reinvested over
exactly the same dates. "Beats QQQ" means a higher daily Sharpe ratio
(mean daily return / standard deviation x sqrt(252), no risk-free rate).

Complete the AI-assisted setup flow at https://finlab.finance/en/setup before running this file.

    python strategy.py              # every rule at its classic setting, on QQQ and SPY
    python strategy.py --validate   # the three robustness checks from the article
"""
import json
import sys

import pandas as pd
from pandas.tseries.holiday import (
    AbstractHolidayCalendar,
    GoodFriday,
    Holiday,
    USLaborDay,
    USMartinLutherKingJr,
    USMemorialDay,
    USPresidentsDay,
    USThanksgivingDay,
    nearest_workday,
    sunday_to_monday,
)

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

DATA_END = "2026-10-27"   # set to None to run on the latest data
COST_PER_SIDE = 0.001     # 0.1% per buy and per sell, for spread and slippage

# Each rule: two parameters, the classic setting, and one step either side of it.
RULES = {
    "santa": {
        "name": "Santa Claus rally",
        "params": ("last_december_days", "first_january_days"),
        "classic": (5, 2),
        "grid": ((4, 5, 6), (1, 2, 3)),
    },
    "halloween": {
        "name": "Sell in May (hold Nov-Apr)",
        "params": ("first_month_held", "last_month_held"),
        "classic": (11, 4),
        "grid": ((10, 11, 12), (3, 4, 5)),
    },
    "turn_of_month": {
        "name": "Turn of the month",
        "params": ("days_before_month_end", "days_into_month"),
        "classic": (1, 3),
        "grid": ((0, 1, 2), (2, 3, 4)),
    },
    "pre_holiday": {
        "name": "Pre-holiday",
        "params": ("days_before_holiday", "days_after_holiday"),
        "classic": (1, 0),
        "grid": ((1, 2, 3), (0, 1, 2)),
    },
    "may_june": {
        "name": "Cash in May-June",
        "params": ("first_cash_month", "cash_months"),
        "classic": (5, 2),
        "grid": ((4, 5, 6), (1, 2, 3)),
    },
}


# ---------------------------------------------------------------------------
# 1. Trading calendar
# ---------------------------------------------------------------------------
class NYSEHolidays(AbstractHolidayCalendar):
    """Regular NYSE holidays, used only for trading days after the last data row."""

    rules = [
        Holiday("NewYearsDay", month=1, day=1, observance=sunday_to_monday),
        USMartinLutherKingJr,
        USPresidentsDay,
        GoodFriday,
        USMemorialDay,
        Holiday("Juneteenth", month=6, day=19, start_date="2022-01-01", observance=nearest_workday),
        Holiday("IndependenceDay", month=7, day=4, observance=nearest_workday),
        USLaborDay,
        USThanksgivingDay,
        Holiday("Christmas", month=12, day=25, observance=nearest_workday),
    ]


def load_prices():
    data.set_market("us_fund")
    close = data.get("us_fund_price:adj_close")[["QQQ", "SPY"]].dropna()
    if DATA_END:
        close = close.loc[:DATA_END]
    return close


def trading_days(close):
    """Trading days in the data, plus the next two months of scheduled NYSE sessions.

    The calendar is known in advance, so a rule can schedule its trades before the
    window starts. The future sessions let the last rows of the backtest know
    whether, for example, today is the last trading day of the month.
    """
    last = close.index[-1]
    future = pd.bdate_range(last + pd.Timedelta(days=1), last + pd.Timedelta(days=62))
    future = future.difference(NYSEHolidays().holidays(future[0], future[-1]))
    return close.index.append(future)


# ---------------------------------------------------------------------------
# 2. Calendar rules: True on every day whose return the rule holds
# ---------------------------------------------------------------------------
def month_positions(days):
    counter = pd.Series(1, index=days)
    month = days.to_period("M")
    day_in_month = counter.groupby(month).cumcount() + 1                    # 1 = first trading day
    days_to_month_end = counter.groupby(month).cumcount(ascending=False) + 1  # 1 = last trading day
    return day_in_month, days_to_month_end


def market_holidays(days):
    weekdays = pd.bdate_range(days[0], days[-1])
    return weekdays.difference(days)


def hold_days(rule, a, b, days):
    day_in_month, days_to_month_end = month_positions(days)
    month = pd.Series(days.month, index=days)

    if rule == "santa":
        december = (month == 12) & (days_to_month_end <= a)
        january = (month == 1) & (day_in_month <= b)
        return december | january

    if rule == "halloween":
        held_months = [(a - 1 + i) % 12 + 1 for i in range((b - a) % 12 + 1)]
        return month.isin(held_months)

    if rule == "turn_of_month":
        return (days_to_month_end <= a) | (day_in_month <= b)

    if rule == "pre_holiday":
        hold = pd.Series(False, index=days)
        for holiday in market_holidays(days):
            next_day = days.searchsorted(holiday)          # first trading day after the holiday
            hold.iloc[max(next_day - a, 0):next_day] = True
            hold.iloc[next_day:next_day + b] = True
        return hold

    if rule == "may_june":
        cash_months = [(a - 1 + i) % 12 + 1 for i in range(b)]
        return ~month.isin(cash_months)

    raise ValueError(rule)


def build_position(rule, a, b, close, ticker="QQQ"):
    """Position table for finlab's sim().

    sim() trades a signal at the next trading day's close. To own the ETF over day
    x (close of x-1 to close of x) the buy happens at the close of x-1, so the
    signal is set on x-2. That is the hold calendar shifted two trading days earlier.
    """
    days = trading_days(close)
    hold = hold_days(rule, a, b, days)
    signal = hold.shift(-2, fill_value=False).reindex(close.index)
    return signal.astype(float).to_frame(ticker)


def run_backtest(position, fee_ratio=None):
    data.set_market("us_fund")
    return sim(
        position,
        resample=None,             # trade whenever the calendar says so
        trade_at_price="close",
        fee_ratio=fee_ratio,       # None = finlab's US default (0 commission, 0 tax)
        upload=False,
    )


# ---------------------------------------------------------------------------
# 3. Statistics
# ---------------------------------------------------------------------------
def daily_sharpe(equity):
    returns = equity.pct_change().dropna()
    return float(returns.mean() / returns.std() * 252 ** 0.5)


def curve_stats(equity):
    returns = equity.pct_change().dropna()
    years = (equity.index[-1] - equity.index[0]).days / 365.25
    return {
        "sharpe": daily_sharpe(equity),
        "cagr": float((equity.iloc[-1] / equity.iloc[0]) ** (1 / years) - 1),
        "mdd": float((equity / equity.cummax() - 1).min()),
        "vol": float(returns.std() * 252 ** 0.5),
        "total_return": float(equity.iloc[-1] / equity.iloc[0] - 1),
    }


def equity_curve(report, close):
    """sim() starts the curve at the first trade; start every rule on the first data day."""
    return report.creturn.reindex(close.index).ffill().fillna(1.0)


def buy_and_hold(close, ticker, index):
    return close[ticker].reindex(index).ffill()


def summarize(report, position, close):
    equity = equity_curve(report, close)
    stats = curve_stats(equity)
    trades = report.trades
    stats.update({
        "time_in_market": float(position.iloc[:, 0].mean()),
        "trades": int(len(trades)),
        "trades_per_year": float(len(trades) / ((equity.index[-1] - equity.index[0]).days / 365.25)),
        "win_rate": float((trades["return"] > 0).mean()) if len(trades) else None,
        "avg_trade_return": float(trades["return"].mean()) if len(trades) else None,
    })
    return stats


def classic_results(close):
    results = {}
    for rule, spec in RULES.items():
        a, b = spec["classic"]
        results[rule] = {}
        for ticker in ("QQQ", "SPY"):
            position = build_position(rule, a, b, close, ticker)
            report = run_backtest(position)
            results[rule][ticker] = summarize(report, position, close)
            results[rule][ticker]["equity"] = equity_curve(report, close)
    benchmarks = {t: curve_stats(buy_and_hold(close, t, close.index)) for t in ("QQQ", "SPY")}
    return results, benchmarks


def print_classic(results, benchmarks):
    print(f"{'rule':<28}{'ETF':<5}{'Sharpe':>8}{'CAGR':>8}{'MDD':>8}{'in mkt':>8}{'trades':>8}")
    for rule, spec in RULES.items():
        for ticker, s in results[rule].items():
            print(f"{spec['name']:<28}{ticker:<5}{s['sharpe']:>8.2f}{s['cagr']:>8.1%}"
                  f"{s['mdd']:>8.1%}{s['time_in_market']:>8.0%}{s['trades']:>8d}")
    for ticker, s in benchmarks.items():
        print(f"{'Buy and hold':<28}{ticker:<5}{s['sharpe']:>8.2f}{s['cagr']:>8.1%}{s['mdd']:>8.1%}")


# ---------------------------------------------------------------------------
# 4. Robustness checks:  python strategy.py --validate
# ---------------------------------------------------------------------------
def sharpe_vs_qqq(equity, close):
    return daily_sharpe(equity), daily_sharpe(buy_and_hold(close, "QQQ", equity.index))


def validate(rule, close, ticker="QQQ"):
    spec = RULES[rule]
    out = {"ticker": ticker, "grid": [], "halves": [], "cost": None}
    print(f"--- {spec['name']} on {ticker}")

    # 1. Neighbouring parameters: the classic value and one step either side, 9 sets.
    for a in spec["grid"][0]:
        for b in spec["grid"][1]:
            equity = equity_curve(run_backtest(build_position(rule, a, b, close, ticker)), close)
            strategy, qqq = sharpe_vs_qqq(equity, close)
            out["grid"].append({spec["params"][0]: a, spec["params"][1]: b,
                                "sharpe": strategy, "qqq_sharpe": qqq, "wins": strategy > qqq})
            print(f"{spec['params'][0]}={a} {spec['params'][1]}={b}: "
                  f"Sharpe {strategy:.2f} vs QQQ {qqq:.2f}")
    wins = sum(row["wins"] for row in out["grid"])
    out["grid_wins"] = wins
    print(f"{wins}/9 parameter sets beat QQQ")

    # 2. First half and second half of the backtest, each on its own.
    position = build_position(rule, *spec["classic"], close, ticker)
    equity = equity_curve(run_backtest(position), close)
    middle = equity.index[len(equity) // 2]
    for label, part in (("first half", equity.loc[:middle]), ("second half", equity.loc[middle:])):
        strategy, qqq = sharpe_vs_qqq(part, close)
        out["halves"].append({"label": label, "start": str(part.index[0].date()),
                              "end": str(part.index[-1].date()),
                              "sharpe": strategy, "qqq_sharpe": qqq, "wins": strategy > qqq})
        print(f"{label} {part.index[0].date()} to {part.index[-1].date()}: "
              f"Sharpe {strategy:.2f} vs QQQ {qqq:.2f}")

    # 3. Trading costs: finlab's US default is zero, so charge 0.1% per side.
    equity = equity_curve(run_backtest(position, fee_ratio=COST_PER_SIDE), close)
    stats = curve_stats(equity)
    strategy, qqq = sharpe_vs_qqq(equity, close)
    out["cost"] = {"cagr": stats["cagr"], "sharpe": strategy, "qqq_sharpe": qqq, "wins": strategy > qqq}
    print(f"with 0.1% per side: CAGR {stats['cagr']:.1%}, Sharpe {strategy:.2f} vs QQQ {qqq:.2f}")

    out["passes"] = wins >= 7 and all(h["wins"] for h in out["halves"]) and out["cost"]["wins"]
    print("passes all three checks" if out["passes"] else "does not pass")
    return out


def main():
    finlab.login()  # finlab guides you through login automatically
    close = load_prices()
    results, benchmarks = classic_results(close)
    print_classic(results, benchmarks)

    if "--validate" in sys.argv:
        checks = {rule: {t: validate(rule, close, t) for t in ("QQQ", "SPY")} for rule in RULES}
        metrics = {
            "data_end": str(close.index[-1].date()),
            "cost_per_side": COST_PER_SIDE,
            "benchmarks": benchmarks,
            "rules": {rule: {"name": RULES[rule]["name"], "params": RULES[rule]["params"],
                             "classic": RULES[rule]["classic"],
                             "results": {t: {k: v for k, v in s.items() if k != "equity"}
                                         for t, s in results[rule].items()},
                             "checks": checks[rule]} for rule in RULES},
        }
        with open("metrics.json", "w") as f:
            json.dump(metrics, f, indent=1, default=float)

        curves = {f"{rule}_{t}": s["equity"] for rule in RULES for t, s in results[rule].items()}
        curves.update({f"buy_hold_{t}": buy_and_hold(close, t, close.index) for t in ("QQQ", "SPY")})
        table = pd.DataFrame(curves)
        (table / table.iloc[0]).round(6).to_csv("data.csv", index_label="date")


if __name__ == "__main__":
    main()
