{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# QuantBattle · primer modelo para una ronda de práctica\n",
        "\n",
        "Este notebook descarga datos públicos, entrena exclusivamente con train, selecciona una configuración con validation y genera predictions.csv para live, que no contiene etiquetas.\n",
        "\n",
        "La modalidad predeterminada usa un **mercado sintético**. Los IDs identifican filas, no revelan la identidad de un activo. Las eras públicas están permutadas: no ordenes temporalmente por su nombre. El score oficial es la media de Spearman por era después del cierre; no mide rentabilidad. No hay premios QP en torneos ML.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "%pip install -q pandas==2.2.2 numpy==1.26.4 scipy==1.17.1 pyarrow==17.0.0 scikit-learn==1.9.0 requests\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "import os, io, hashlib\n",
        "import numpy as np\n",
        "import pandas as pd\n",
        "import requests\n",
        "from sklearn.ensemble import HistGradientBoostingRegressor\n",
        "\n",
        "WORKER_URL = os.environ.get(\"QUANTBATTLE_WORKER_URL\", \"https://quantlab2.onrender.com\").rstrip(\"/\")\n",
        "TOURNAMENT_SLUG = os.environ.get(\"QUANTBATTLE_TOURNAMENT_SLUG\", \"ml-practice\")\n",
        "\n",
        "def get_json(path, **params):\n",
        "    response = requests.get(WORKER_URL + path, params=params, timeout=90)\n",
        "    response.raise_for_status()\n",
        "    return response.json()\n",
        "\n",
        "tournaments = get_json(\"/tournament/ml\")\n",
        "tournament = next(t for t in tournaments if t[\"slug\"] == TOURNAMENT_SLUG)\n",
        "datasets = get_json(\"/ml/datasets\", tournament_id=tournament[\"id\"])[\"datasets\"]\n",
        "round_number = max(d[\"round_number\"] for d in datasets if d[\"kind\"] == \"live\")\n",
        "records = {d[\"kind\"]: d for d in datasets if d[\"round_number\"] == round_number}\n",
        "assert set(records) == {\"train\", \"validation\", \"live\"}\n",
        "\n",
        "def read_frame(record):\n",
        "    url = get_json(f\"/ml/datasets/{record['id']}/download\", kind=record[\"kind\"])[\"url\"]\n",
        "    response = requests.get(url, timeout=90)\n",
        "    response.raise_for_status()\n",
        "    frame = pd.read_parquet(io.BytesIO(response.content))\n",
        "    assert len(frame) == record[\"row_count\"] and frame[\"id\"].is_unique\n",
        "    return frame\n",
        "\n",
        "train, validation, live = [read_frame(records[k]) for k in (\"train\", \"validation\", \"live\")]\n",
        "assert \"target\" in train and \"target\" in validation and \"target\" not in live\n",
        "features = records[\"train\"][\"feature_cols\"]\n",
        "assert np.isfinite(train[features + [\"target\"]].to_numpy()).all()\n",
        "assert np.isfinite(validation[features + [\"target\"]].to_numpy()).all()\n",
        "assert np.isfinite(live[features].to_numpy()).all()\n",
        "print(\"Versión:\", records[\"live\"][\"version\"])\n",
        "print(\"Cierre UTC:\", records[\"live\"][\"closes_at\"])\n",
        "print(\"Filas train / validation / live:\", len(train), len(validation), len(live))\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Selección usando solo validation\n",
        "\n",
        "Se comparan dos configuraciones de HistGradientBoostingRegressor con semilla fija. No se reentrena con validation para mantener exactamente el procedimiento de referencia del piloto técnico. Las variables contienen señal y ruido; ninguna configuración tiene una victoria garantizada.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "def ranked(frame, values):\n",
        "    ranks = pd.Series(values, index=frame.index).groupby(frame[\"era\"]).rank(pct=True)\n",
        "    return pd.DataFrame({\"id\": frame[\"id\"], \"prediction\": ranks})\n",
        "\n",
        "def scores_by_era(frame, prediction):\n",
        "    checked = frame[[\"id\", \"era\", \"target\"]].merge(prediction, on=\"id\", validate=\"one_to_one\")\n",
        "    return pd.Series({\n",
        "        era: rows[\"prediction\"].corr(rows[\"target\"], method=\"spearman\")\n",
        "        for era, rows in checked.groupby(\"era\")\n",
        "    })\n",
        "\n",
        "best_model, best_score = None, float(\"-inf\")\n",
        "for leaves in (7, 15):\n",
        "    model = HistGradientBoostingRegressor(\n",
        "        max_iter=100, max_leaf_nodes=leaves, learning_rate=0.05,\n",
        "        l2_regularization=1.0, random_state=42,\n",
        "    )\n",
        "    model.fit(train[features], train[\"target\"])\n",
        "    scores = scores_by_era(validation, ranked(validation, model.predict(validation[features])))\n",
        "    print({\"max_leaf_nodes\": leaves, \"validation_spearman_mean\": scores.mean(), \"std\": scores.std(ddof=0)})\n",
        "    if scores.mean() > best_score:\n",
        "        best_model, best_score = model, scores.mean()\n",
        "print(\"Configuración seleccionada:\", best_model.max_leaf_nodes)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Generar el archivo para enviar\n",
        "\n",
        "Las predicciones se convierten en rangos entre 0 y 1 dentro de cada era. El CSV incluye todos los IDs de live una sola vez. El servidor vuelve a validarlo, guarda un recibo SHA-256 y selecciona el último envío válido antes del plazo. Un archivo inválido no reemplaza el anterior.\n",
        "\n",
        "El notebook no contiene claves de servicio ni solicita el target privado.\n"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "predictions = ranked(live, best_model.predict(live[features]))\n",
        "assert predictions[\"id\"].is_unique and set(predictions[\"id\"]) == set(live[\"id\"])\n",
        "assert predictions[\"prediction\"].between(0, 1).all()\n",
        "checked = predictions.merge(live[[\"id\", \"era\"]], on=\"id\", validate=\"one_to_one\")\n",
        "assert checked.groupby(\"era\")[\"prediction\"].nunique().gt(1).all()\n",
        "predictions.to_csv(\"predictions.csv\", index=False, encoding=\"utf-8\")\n",
        "payload = open(\"predictions.csv\", \"rb\").read()\n",
        "assert len(payload) <= 10 * 1024 * 1024\n",
        "print(\"Filas:\", len(predictions))\n",
        "print(\"SHA-256 del archivo:\", hashlib.sha256(payload).hexdigest())\n",
        "print(\"Enviar en:\", f\"https://www.quantbattle.co/app/tournaments/{tournament['id']}\")\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## Leer el resultado después del cierre\n",
        "\n",
        "En la pestaña Enviar de QuantBattle, cargar predictions.csv. Antes del cierre se verá la confirmación de validación, sin score. Después del cierre, el scheduler puntúa y publica el ranking. Conservar la versión, los parámetros y el archivo permite reproducir la entrega. Una puntuación positiva en datos artificiales no demuestra capacidad de pronosticar mercados reales.\n",
        "\n",
        "El piloto técnico publicado en /docs#evaluacion corresponde a una ronda separada y una cuenta técnica, identificadas como tales.\n"
      ]
    }
  ],
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python",
      "version": "3.11"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 5
}
