import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import TimeSeriesSplit
np.random.seed(42)
# =========================================================
# 0) Create a tiny CSV (self-contained drill)
# =========================================================
n = 420
dates = pd.date_range("2024-01-01", periods=n, freq="B")
t = np.arange(n)
price = 100 * np.exp(0.0005 * t + 0.02 * np.sin(2*np.pi*t/20) + 0.01*np.random.randn(n))
volume = 1_000 + 150*np.sin(2*np.pi*t/5) + 50*np.random.randn(n)
raw = pd.DataFrame({"date": dates, "price": price, "volume": volume})
# Inject issues: missing values + duplicate timestamp + unsorted rows
raw.loc[50, "price"] = np.nan
raw.loc[120, "volume"] = np.nan
raw = pd.concat([raw, raw.iloc[[200]]], ignore_index=True) # duplicate one row
raw = raw.sample(frac=1.0, random_state=1).reset_index(drop=True) # shuffle
csv_path = "mini_drill_timeseries.csv"
raw.to_csv(csv_path, index=False)
# =========================================================
# 1) Read CSV
# =========================================================
df = pd.read_csv(csv_path, parse_dates=["date"])
# =========================================================
# 2) Clean
# =========================================================
df = df.sort_values("date")
df = df.drop_duplicates(subset=["date"], keep="last")
# Missing policy (documented for drill)
df["price"] = df["price"].ffill()
df["volume"] = df["volume"].fillna(df["volume"].rolling(10, min_periods=1).median())
# Invariants
assert df["date"].is_monotonic_increasing
assert df["date"].is_unique
# =========================================================
# 3) Features and target
# Target: next-day log return y_t = r_{t+1}
# =========================================================
df["log_price"] = np.log(df["price"])
df["r1"] = df["log_price"].diff() # r_t
df["y"] = df["r1"].shift(-1) # y_t = r_{t+1}
# Causal features at time t
df["lag1"] = df["r1"].shift(1)
df["lag2"] = df["r1"].shift(2)
df["vol_20"] = df["r1"].rolling(20).std() # trailing volatility proxy
df["dow"] = df["date"].dt.dayofweek # day-of-week
feat_cols = ["lag1", "lag2", "vol_20", "dow"]
df_feat = df.dropna(subset=feat_cols + ["y"]).reset_index(drop=True)
X = df_feat[feat_cols].values
y = df_feat["y"].values
# =========================================================
# 4) TimeSeriesSplit
# =========================================================
tscv = TimeSeriesSplit(n_splits=5)
# =========================================================
# 5) Two baselines
# (A) Last value baseline: yhat_t = r_t
# (B) Seasonal naive baseline (weekly): yhat_t = r_{t-5}
# =========================================================
def rmse(a, b):
return float(np.sqrt(np.mean((a - b) ** 2)))
def mae(a, b):
return float(np.mean(np.abs(a - b)))
fold_rows = []
pred_last = np.full_like(y, np.nan, dtype=float)
pred_seas = np.full_like(y, np.nan, dtype=float)
for k, (train_idx, test_idx) in enumerate(tscv.split(X), start=1):
# Baseline A: predict next return equals current return
yhat_last = df_feat.loc[test_idx, "r1"].values
# Baseline B: seasonal naive using weekly lag (5 business days)
# Need r_{t-5} available at time t => shift r1 by 5
yhat_seas = df_feat.loc[test_idx, "r1"].shift(5).values
# For early rows in fold, seasonal may be NaN; drop those for fair scoring
ytrue = y[test_idx]
m_last_rmse = rmse(ytrue, yhat_last)
m_last_mae = mae(ytrue, yhat_last)
mask = ~np.isnan(yhat_seas)
m_seas_rmse = rmse(ytrue[mask], yhat_seas[mask])
m_seas_mae = mae(ytrue[mask], yhat_seas[mask])
fold_rows.append({
"fold": k,
"n_test": len(test_idx),
"rmse_last": m_last_rmse,
"mae_last": m_last_mae,
"rmse_seasonal5": m_seas_rmse,
"mae_seasonal5": m_seas_mae,
})
pred_last[test_idx] = yhat_last
pred_seas[test_idx] = yhat_seas
fold_df = pd.DataFrame(fold_rows)
# =========================================================
# 6) One-slide summary table (print + optional plot)
# =========================================================
summary = pd.DataFrame({
"baseline": ["Last value (yhat=r_t)", "Seasonal naive (yhat=r_{t-5})"],
"RMSE_mean": [float(fold_df["rmse_last"].mean()), float(fold_df["rmse_seasonal5"].mean())],
"RMSE_worst_fold": [float(fold_df["rmse_last"].max()), float(fold_df["rmse_seasonal5"].max())],
"MAE_mean": [float(fold_df["mae_last"].mean()), float(fold_df["mae_seasonal5"].mean())],
})
print("=== Fold metrics (TimeSeriesSplit) ===")
print(fold_df.to_string(index=False))
print("\n=== One-slide summary ===")
print(summary.to_string(index=False))
# Plot: realised vs both baselines (subset)
mask = ~np.isnan(pred_last)
mask2 = mask & ~np.isnan(pred_seas)
plt.figure(figsize=(8, 3.8))
plt.plot(df_feat.loc[mask, "date"].values, y[mask], label="Realised y = r_{t+1}")
plt.plot(df_feat.loc[mask, "date"].values, pred_last[mask], label="Baseline A: yhat = r_t")
plt.plot(df_feat.loc[mask2, "date"].values, pred_seas[mask2], label="Baseline B: yhat = r_{t-5}")
plt.axhline(0, linestyle="--")
plt.title("Two baselines under TimeSeriesSplit (next-day log return)")
plt.xlabel("date")
plt.ylabel("log return")
plt.legend()
plt.tight_layout()
plt.show()
# =========================================================
# 7) Explain result (slide-ready bullets)
# =========================================================
explain = """
Slide-ready explanation:
- Target is next-day log return y_t = r_{t+1}.
- We use TimeSeriesSplit to preserve time order and avoid leakage.
- Baseline A (last value) tests short-memory persistence in returns.
- Baseline B (seasonal naive, lag 5) tests weekly structure.
- If Baseline B wins, the series contains weekly seasonality in returns.
- If both are similar, predictability is weak and RMSE mostly reflects volatility.
- Any real model must beat the stronger baseline out-of-sample, fold by fold, not only on average.
""".strip()
print("\n" + explain)