{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# EcoFinLearn — Build a Simple ML Tool: Return Classifier + MLflow\n",
    "\n",
    "You have been running ML in the browser on [ecofinlearning.com](https://ecofinlearning.com). This notebook is the bridge to **real tools** — the same ideas, but in Google Colab where you get real plots, model files you can keep, and **MLflow experiment tracking**.\n",
    "\n",
    "**What you will build:** a next-day direction classifier on real market data, with every experiment logged and compared — a workflow you can reuse for your dissertation.\n",
    "\n",
    "*Runtime → Run all* to execute the whole notebook. It falls back to synthetic data if the market download is unavailable, so it always runs."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 0. Setup\n",
    "\n",
    "Colab does not ship MLflow or yfinance — install them once:"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "!pip install -q mlflow yfinance"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import warnings; warnings.filterwarnings('ignore')\n",
    "import numpy as np, pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "from sklearn.pipeline import make_pipeline\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.ensemble import RandomForestClassifier\n",
    "from sklearn.metrics import accuracy_score, confusion_matrix\n",
    "import mlflow, mlflow.sklearn\n",
    "print('ready — mlflow', mlflow.__version__)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. Get the data\n",
    "\n",
    "We pull daily prices from Yahoo Finance. If that fails (offline / rate-limited), we fall back to a synthetic price series with mild momentum — so the notebook never breaks."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "TICKER = 'AAPL'\n",
    "\n",
    "def load_prices(ticker=TICKER):\n",
    "    try:\n",
    "        import yfinance as yf\n",
    "        df = yf.download(ticker, start='2021-01-01', progress=False, auto_adjust=True)\n",
    "        if len(df) > 100:\n",
    "            out = df[['Close']].copy()\n",
    "            out.columns = ['Close']\n",
    "            print(f'Loaded {len(out)} rows of real {ticker} data from Yahoo Finance.')\n",
    "            return out\n",
    "    except Exception as e:\n",
    "        print('Yahoo download failed:', e)\n",
    "    # --- synthetic fallback: random walk with 0.18 AR(1) momentum ---\n",
    "    rng = np.random.default_rng(0)\n",
    "    n = 700\n",
    "    idx = pd.bdate_range('2021-01-04', periods=n)\n",
    "    eps = rng.normal(0, 0.011, n); steps = np.zeros(n)\n",
    "    for i in range(1, n):\n",
    "        steps[i] = 0.0003 + 0.18 * steps[i-1] + eps[i]\n",
    "    print('Using synthetic fallback data (700 business days).')\n",
    "    return pd.DataFrame({'Close': 100 * np.exp(np.cumsum(steps))}, index=idx)\n",
    "\n",
    "prices = load_prices()\n",
    "prices.tail(3)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. Leak-free features\n",
    "\n",
    "The one rule that separates a real backtest from a fantasy: **a feature for day *t* may only use information available *before* day *t*.** Every predictor below ends in `.shift(1)`.\n",
    "\n",
    "The target is today's direction; the features are all from yesterday or earlier."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "df = prices.copy()\n",
    "df['log_ret'] = np.log(df['Close'] / df['Close'].shift(1))\n",
    "df['mom_1d']  = df['log_ret'].shift(1)                       # yesterday's return\n",
    "df['mom_5d']  = df['log_ret'].rolling(5).sum().shift(1)      # last week's momentum\n",
    "df['vol_20']  = df['log_ret'].rolling(20).std().shift(1)     # recent volatility\n",
    "df['target']  = (df['log_ret'] > 0).astype(int)             # was TODAY up?\n",
    "df = df.dropna()\n",
    "\n",
    "FEATURES = ['mom_1d', 'mom_5d', 'vol_20']\n",
    "X, y = df[FEATURES], df['target']\n",
    "print(f'{len(df)} rows, {len(FEATURES)} features, up-day rate = {y.mean():.1%}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. Split by time — never shuffle\n",
    "\n",
    "Shuffling a time series lets the model peek at the future. Train on the past, test on the future:"
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "cut = int(len(df) * 0.7)\n",
    "X_train, X_test = X.iloc[:cut], X.iloc[cut:]\n",
    "y_train, y_test = y.iloc[:cut], y.iloc[cut:]\n",
    "print(f'train {len(X_train)}  ->  test {len(X_test)}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. Train and track every run with MLflow\n",
    "\n",
    "This is the new tool. `mlflow.start_run()` records the parameters, metrics and the model file for each experiment, so you can compare them later instead of losing track in a mess of notebook cells.\n",
    "\n",
    "We score with **accuracy** *and* an **asymmetric cost**: a false *up* (going long into a fall) hurts 3× a missed rally — because in markets, losing money is worse than missing out. We also record the *always-out* baseline (never trade) to keep ourselves honest."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "COST_FP, COST_FN = 3.0, 1.0\n",
    "always_out_cost = int((y_test == 1).sum()) * COST_FN   # every up-day is a missed rally\n",
    "mlflow.set_experiment('return-classifier')\n",
    "\n",
    "def train_and_log(name, model):\n",
    "    with mlflow.start_run(run_name=name):\n",
    "        model.fit(X_train, y_train)\n",
    "        pred = model.predict(X_test)\n",
    "        acc = accuracy_score(y_test, pred)\n",
    "        tn, fp, fn, tp = confusion_matrix(y_test, pred, labels=[0, 1]).ravel()\n",
    "        net_cost = fp * COST_FP + fn * COST_FN\n",
    "        mlflow.log_param('model', name)\n",
    "        mlflow.log_param('features', ', '.join(FEATURES))\n",
    "        mlflow.log_metric('accuracy', acc)\n",
    "        mlflow.log_metric('net_cost', net_cost)\n",
    "        mlflow.log_metric('beats_baseline', float(net_cost < always_out_cost))\n",
    "        mlflow.sklearn.log_model(model, name='model')\n",
    "        print(f'{name:16s} acc={acc:.3f}  net_cost={net_cost:.0f}  (baseline {always_out_cost})')\n",
    "        return model\n",
    "\n",
    "logreg = train_and_log('logreg', make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)))\n",
    "rf     = train_and_log('random_forest', RandomForestClassifier(n_estimators=200, max_depth=3, random_state=0))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. Compare the runs\n",
    "\n",
    "`mlflow.search_runs()` returns every experiment as a DataFrame — the whole point of tracking. Sort by cost to see which model actually wins."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "runs = mlflow.search_runs(order_by=['metrics.net_cost ASC'])\n",
    "runs[['params.model', 'metrics.accuracy', 'metrics.net_cost', 'metrics.beats_baseline']]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 6. Read the confusion, not just the accuracy\n",
    "\n",
    "Accuracy can beat a coin flip while the strategy still *loses money*, because the errors are not equally costly. The plot shows where each model goes wrong."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(1, 2, figsize=(9, 3.4))\n",
    "for ax, (name, m) in zip(axes, [('logreg', logreg), ('random_forest', rf)]):\n",
    "    cm = confusion_matrix(y_test, m.predict(X_test), labels=[0, 1])\n",
    "    im = ax.imshow(cm, cmap='Blues')\n",
    "    ax.set_title(name); ax.set_xlabel('predicted'); ax.set_ylabel('actual')\n",
    "    ax.set_xticks([0, 1], ['down', 'up']); ax.set_yticks([0, 1], ['down', 'up'])\n",
    "    for (i, j), v in np.ndenumerate(cm):\n",
    "        ax.text(j, i, int(v), ha='center', va='center', fontsize=12)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 7. Save the model — it is a file now\n",
    "\n",
    "Unlike the browser, in Colab your trained model persists. Save it, and you can load it into any Python program (a dashboard, an API, your dissertation appendix)."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import joblib\n",
    "joblib.dump(rf, 'return_classifier.joblib')\n",
    "reloaded = joblib.load('return_classifier.joblib')\n",
    "print('saved + reloaded; sanity check accuracy =',\n",
    "      round(accuracy_score(y_test, reloaded.predict(X_test)), 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## You built an ML tool\n",
    "\n",
    "- **Leak-free features** and a **time-based split** — the discipline that makes a backtest honest.\n",
    "- **MLflow tracking** — every run's params, metrics and model saved and comparable.\n",
    "- An **asymmetric-cost** evaluation that a naive accuracy score hides.\n",
    "- A **saved model file** you can reuse.\n",
    "\n",
    "**Next steps for your own project / dissertation:**\n",
    "1. Swap `TICKER` for your asset, or load your own CSV in place of `load_prices()`.\n",
    "2. Add features (RSI, macro from FRED, cross-sectional rank) — log each idea as a new MLflow run.\n",
    "3. Open the MLflow UI in Colab: `from google.colab import output; get_ipython().system_raw('mlflow ui --port 5000 &'); output.serve_kernel_port_as_window(5000)`.\n",
    "4. Read the data-sourcing guide at [ecofinlearning.com/thesis-data](https://ecofinlearning.com/thesis-data).\n",
    "\n",
    "*Built for the EcoFinLearn Machine Learning track — [ecofinlearning.com/learn/python-ml](https://ecofinlearning.com/learn/python-ml).*"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": [],
   "toc_visible": true
  },
  "kernelspec": {
   "name": "python3",
   "display_name": "Python 3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 0
}