{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Fantabeto 26/27 — Auction Strategy & Matchday 1 Prediction Mockup\n", "\n", "This notebook demonstrates the 2026/2027 season tools:\n", "1. **Auction Optimizer** — MILP-based draft strategy with budget allocation\n", "2. **Matchday 1 Predictions** — GBM ensemble projections for the opening fixtures\n", "3. **Lineup Optimizer** — MCTS-based starting XI selection\n", "\n", "> ⚠️ **August 2026 Note**: The 26/27 transfer window is active. Player projections use 2025/26 stats regressed toward historical baselines. Predictions will improve as matchday data accumulates." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import sys\n", "sys.path.insert(0, '..')\n", "\n", "import numpy as np\n", "import pandas as pd\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "\n", "sns.set_style(\"whitegrid\")\n", "plt.rcParams[\"figure.figsize\"] = (12, 6)\n", "np.random.seed(42)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Part 1: Auction Strategy (MILP Knapsack)\n", "\n", "We formulate the Fantacalcio draft as a multi-period stochastic knapsack:\n", "$$\\max \\sum_{i} p_i \\cdot x_i \\quad \\text{s.t.} \\quad \\sum_i c_i x_i \\leq B, \\quad \\sum_{i \\in GK} x_i = 3, \\quad \\sum_{i \\in DEF} x_i = 8, \\quad \\ldots$$\n", "\n", "Where $p_i$ is projected Fantavoto, $c_i$ is estimated market price, and $B=500$ crediti." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from src.optimization.auction_solver import AuctionSolver, AuctionConfig\n", "\n", "# Simulate 2026/27 player pool with reasonable projections\n", "np.random.seed(42)\n", "n_players = 400\n", "\n", "teams_26_27 = [\n", " \"Inter\", \"Milan\", \"Juventus\", \"Napoli\", \"Roma\", \"Lazio\",\n", " \"Atalanta\", \"Fiorentina\", \"Bologna\", \"Torino\", \"Udinese\",\n", " \"Monza\", \"Lecce\", \"Cagliari\", \"Empoli\", \"Genoa\",\n", " \"Parma\", \"Como\", \"Frosinone\", \"Sassuolo\",\n", "]\n", "\n", "player_pool = []\n", "for i in range(n_players):\n", " role = np.random.choice([\"P\", \"D\", \"C\", \"A\"], p=[0.06, 0.35, 0.34, 0.25])\n", " # Role-specific point ranges\n", " role_means = {\"P\": 5.5, \"D\": 5.8, \"C\": 6.3, \"A\": 7.0}\n", " role_stds = {\"P\": 1.0, \"D\": 0.8, \"C\": 1.0, \"A\": 1.5}\n", " \n", " projected = np.clip(np.random.normal(role_means[role], role_stds[role]), 3, 10)\n", " market_value = np.random.randint(1, 50)\n", " \n", " player_pool.append({\n", " \"name\": f\"Player_{i}\",\n", " \"team\": np.random.choice(teams_26_27),\n", " \"role\": role,\n", " \"projected_points\": round(projected, 2),\n", " \"market_value\": market_value,\n", " })\n", "\n", "pool_df = pd.DataFrame(player_pool)\n", "print(f\"Player pool: {len(pool_df)} players from {len(teams_26_27)} teams\")\n", "print(f\"Role distribution:\\n{pool_df['role'].value_counts()}\")\n", "pool_df.head()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Solve the auction\n", "config = AuctionConfig(\n", " total_budget=500,\n", " n_gk=3, n_def=8, n_mid=8, n_fwd=6,\n", " max_single_bid_pct=0.4,\n", ")\n", "\n", "solver = AuctionSolver(config=config)\n", "solver.add_players(pool_df)\n", "result = solver.solve()\n", "\n", "squad = result[\"selected_players\"]\n", "print(f\"Status: {result['status']}\")\n", "print(f\"Total cost: {result['total_cost']:.0f} / {config.total_budget}\")\n", "print(f\"Remaining: {result['remaining_budget']:.0f}\")\n", "print(f\"Projected value: {result['total_value']:.1f} FV\")\n", "print()\n", "\n", "for role in [\"P\", \"D\", \"C\", \"A\"]:\n", " rdf = squad[squad[\"role\"] == role]\n", " print(f\"--- {role} ({len(rdf)}) ---\")\n", " for _, p in rdf.iterrows():\n", " print(f\" {p['player']:20s} | {p['team']:10s} | Price: {p['estimated_price']:6.0f} | FV: {p['projected_points']:5.2f}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Visualize budget allocation by role\n", "fig, axes = plt.subplots(1, 3, figsize=(16, 5))\n", "\n", "# Role distribution\n", "role_summary = squad.groupby(\"role\").agg(\n", " cost=(\"estimated_price\", \"sum\"),\n", " points=(\"projected_points\", \"sum\"),\n", " count=(\"player\", \"count\"),\n", ").reset_index()\n", "\n", "colors = {\"P\": \"#e74c3c\", \"D\": \"#3498db\", \"C\": \"#2ecc71\", \"A\": \"#f39c12\"}\n", "\n", "axes[0].bar(role_summary[\"role\"], role_summary[\"cost\"],\n", " color=[colors[r] for r in role_summary[\"role\"]])\n", "axes[0].set_title(\"Budget per Role\")\n", "axes[0].set_ylabel(\"Crediti\")\n", "\n", "axes[1].bar(role_summary[\"role\"], role_summary[\"points\"],\n", " color=[colors[r] for r in role_summary[\"role\"]])\n", "axes[1].set_title(\"Projected FV per Role\")\n", "axes[1].set_ylabel(\"Fantavoto Points\")\n", "\n", "# Value efficiency (points per credito)\n", "squad[\"efficiency\"] = squad[\"projected_points\"] / squad[\"estimated_price\"]\n", "eff_by_role = squad.groupby(\"role\")[\"efficiency\"].mean()\n", "axes[2].bar(eff_by_role.index, eff_by_role.values,\n", " color=[colors[r] for r in eff_by_role.index])\n", "axes[2].set_title(\"Value Efficiency (FV/Credito)\")\n", "axes[2].set_ylabel(\"Points per Credito\")\n", "\n", "plt.tight_layout()\n", "plt.suptitle(\"Auction Strategy — Squad Allocation\", fontsize=14, y=1.02)\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Part 2: Grid Auction (Asta a Griglia) Example\n", "\n", "In a grid auction, N players are simultaneously available. We use game theory to determine optimal bids." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from src.optimization.auction_solver import PlayerValuation\n", "\n", "# Simulate a grid round: 3 strikers available simultaneously\n", "grid_round = [\n", " PlayerValuation(\"Lautaro\", \"Inter\", \"A\", 9.2, 55, 80),\n", " PlayerValuation(\"Osimhen\", \"Napoli\", \"A\", 8.8, 50, 75),\n", " PlayerValuation(\"Vlahovic\", \"Juventus\", \"A\", 7.8, 40, 60),\n", "]\n", "\n", "recommendations = solver.grid_auction_strategy(grid_round)\n", "\n", "print(\"Grid Auction — Striker Round Strategy:\")\n", "print()\n", "for name, rec in recommendations.items():\n", " print(f\"{name:15s} | Fair: {rec['fair_price']:4.0f} cr | Max bid: {rec['max_bid']:4.0f} cr | Recommended: {rec['recommended_bid']:4.0f} cr\")\n", "\n", "# Game theory insight\n", "print()\n", "print(\"Strategy: Don't overpay. If price exceeds max_bid, wait for the next grid round.\")\n", "print(f\"Best value: {min(recommendations.items(), key=lambda x: x[1]['recommended_bid'] / max(x[1]['value_over_replacement'], 1))[0]}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Part 3: Matchday 1 Predictions (Mockup)\n", "\n", "Simulating predictions for the 2026/27 opening matchday. **Real predictions require trained models.**\n", "\n", "The opening fixtures are based on the 26/27 calendar scraped from Fantacalcio.it." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Matchday 1 mockup fixtures\n", "matchday_1_fixtures = [\n", " (\"Inter\", \"Monza\"),\n", " (\"Udinese\", \"Como\"),\n", " (\"Genoa\", \"Napoli\"),\n", " (\"Parma\", \"Cagliari\"),\n", " (\"Frosinone\", \"Juventus\"),\n", " (\"Venezia\", \"Lecce\"),\n", " (\"Atalanta\", \"Sassuolo\"),\n", " (\"Torino\", \"Milan\"),\n", " (\"Bologna\", \"Lazio\"),\n", " (\"Roma\", \"Fiorentina\"),\n", "]\n", "\n", "# Simulate predictions for ~200 players\n", "np.random.seed(123)\n", "\n", "predictions = []\n", "for fixture in matchday_1_fixtures:\n", " home, away = fixture\n", " # ~10 outfield players per team\n", " for team, opp, is_home in [(home, away, 1), (away, home, 0)]:\n", " for i in range(10):\n", " role_probs = {\"P\": 0.1, \"D\": 0.35, \"C\": 0.35, \"A\": 0.2}\n", " role = np.random.choice(list(role_probs.keys()), p=list(role_probs.values()))\n", " fv_mean = np.random.normal(6.5, 1.0) + (0.3 if is_home else 0)\n", " fv_std = np.random.uniform(0.5, 1.5)\n", " mv_mean = np.random.normal(6.0, 0.5)\n", " starter_prob = np.random.uniform(0.5, 1.0)\n", " \n", " predictions.append({\n", " \"player\": f\"{team[:3]}_P{i}\",\n", " \"team\": team,\n", " \"role\": role,\n", " \"oppteam\": opp,\n", " \"home\": is_home,\n", " \"fv_mean\": round(max(0, fv_mean), 2),\n", " \"fv_std\": round(fv_std, 2),\n", " \"mv_mean\": round(max(0, mv_mean), 2),\n", " \"mv_std\": round(np.random.uniform(0.3, 0.7), 2),\n", " \"starter_prob\": round(starter_prob, 2),\n", " \"cs_prob\": round(np.random.uniform(0.1, 0.3) if role == \"P\" else 0, 2),\n", " })\n", "\n", "preds_df = pd.DataFrame(predictions)\n", "print(f\"Generated {len(preds_df)} player predictions for Matchday 1\")\n", "print(f\"Teams: {preds_df['team'].nunique()}\")\n", "preds_df.head(10)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Top 10 projected players\n", "top10 = preds_df.nlargest(10, \"fv_mean\")[\n", " [\"player\", \"team\", \"oppteam\", \"role\", \"home\", \"fv_mean\", \"fv_std\", \"starter_prob\"]\n", "]\n", "top10" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Visualize: FV distribution by role\n", "fig, ax = plt.subplots(figsize=(10, 6))\n", "\n", "for role, color in [(\"A\", \"#f39c12\"), (\"C\", \"#2ecc71\"), (\"D\", \"#3498db\"), (\"P\", \"#e74c3c\")]:\n", " data = preds_df[preds_df[\"role\"] == role][\"fv_mean\"]\n", " ax.hist(data, bins=20, alpha=0.6, label=f\"{role} (n={len(data)})\", color=color)\n", "\n", "ax.set_xlabel(\"Projected Fantavoto\")\n", "ax.set_ylabel(\"Players\")\n", "ax.set_title(\"Matchday 1 — Fantavoto Distribution by Role\")\n", "ax.legend()\n", "ax.axvline(6.0, color=\"gray\", linestyle=\"--\", alpha=0.5, label=\"Average\")\n", "plt.show()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Home vs Away performance\n", "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", "\n", "for i, (label, df) in enumerate([\n", " (\"Home\", preds_df[preds_df[\"home\"] == 1]),\n", " (\"Away\", preds_df[preds_df[\"home\"] == 0]),\n", "]):\n", " for role in [\"A\", \"C\", \"D\", \"P\"]:\n", " role_data = df[df[\"role\"] == role][\"fv_mean\"]\n", " axes[i].hist(role_data, bins=15, alpha=0.5, label=role)\n", " \n", " axes[i].set_title(f\"{label} Players — FV Distribution\")\n", " axes[i].set_xlabel(\"Projected Fantavoto\")\n", " axes[i].axvline(6.0, color=\"black\", linestyle=\"--\", alpha=0.3)\n", " axes[i].legend()\n", "\n", "plt.suptitle(\"Home vs Away Advantage — Matchday 1\", fontsize=14)\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "home_avg = preds_df[preds_df[\"home\"] == 1][\"fv_mean\"].mean()\n", "away_avg = preds_df[preds_df[\"home\"] == 0][\"fv_mean\"].mean()\n", "print(f\"Home FV avg: {home_avg:.2f} | Away FV avg: {away_avg:.2f} | Advantage: {home_avg - away_avg:+.2f}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Part 4: Lineup Optimization (MCTS)\n", "\n", "Given our squad of 25 players, select the optimal starting 11 and captain using Monte Carlo Tree Search." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from src.optimization.lineup_solver import LineupSolver, PlayerScore\n", "\n", "# Build our squad from top predicted players\n", "squad_size = 25\n", "squad_pool = []\n", "\n", "# Ensure exactly 3 GK, 8 DEF, 8 MID, 6 FWD\n", "quota = {\"P\": 3, \"D\": 8, \"C\": 8, \"A\": 6}\n", "role_counts = {\"P\": 0, \"D\": 0, \"C\": 0, \"A\": 0}\n", "\n", "for _, row in preds_df.iterrows():\n", " role = row[\"role\"]\n", " if role_counts.get(role, 0) < quota.get(role, 0):\n", " role_counts[role] += 1\n", " squad_pool.append(PlayerScore(\n", " name=row[\"player\"],\n", " role=row[\"role\"],\n", " team=row[\"team\"],\n", " oppteam=row[\"oppteam\"],\n", " home=bool(row[\"home\"]),\n", " fv_mean=row[\"fv_mean\"],\n", " fv_std=row[\"fv_std\"],\n", " mv_mean=row[\"mv_mean\"],\n", " mv_std=row[\"mv_std\"],\n", " starter_prob=row[\"starter_prob\"],\n", " cs_prob=row[\"cs_prob\"],\n", " ))\n", " if sum(role_counts.values()) == squad_size:\n", " break\n", "\n", "print(f\"Squad: {len(squad_pool)} players\")\n", "for r in [\"P\", \"D\", \"C\", \"A\"]:\n", " print(f\" {r}: {sum(1 for p in squad_pool if p.role == r)}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Run lineup optimization\n", "solver = LineupSolver(iters=2000)\n", "result = solver.optimize(squad_pool)\n", "\n", "print(\"== Optimal Starting XI ==\")\n", "print(f\"Captain: {result['captain']}\")\n", "print(f\"Expected Points: {result['expected_points']:.1f}\")\n", "print(f\"Win Probability: {result['win_probability']:.1%}\")\n", "print()\n", "\n", "for i, name in enumerate(result[\"lineup\"], 1):\n", " player = next(p for p in squad_pool if p.name == name)\n", " cap_mark = \" (C)\" if name == result[\"captain\"] else \"\"\n", " role_icon = {\"P\": \"GK\", \"D\": \"DEF\", \"C\": \"MID\", \"A\": \"FWD\"}[player.role]\n", " print(f\" {i:2d}. {name:20s} [{role_icon}] FV: {player.fv_mean:.1f} Start: {player.starter_prob:.0%}{cap_mark}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Simulate starting XI total score distribution\n", "lineup_players = [p for p in squad_pool if p.name in result[\"lineup\"]]\n", "for p in lineup_players:\n", " p.captain_multiplier = 2.0 if p.name == result[\"captain\"] else 1.0\n", "\n", "simulated_scores = solver.simulate_match(lineup_players, n_samples=5000)\n", "\n", "fig, ax = plt.subplots(figsize=(10, 6))\n", "ax.hist(simulated_scores, bins=50, color=\"#3498db\", alpha=0.7, edgecolor=\"white\")\n", "ax.axvline(np.mean(simulated_scores), color=\"red\", linestyle=\"--\", linewidth=2,\n", " label=f\"Mean: {np.mean(simulated_scores):.1f}\")\n", "ax.axvline(np.percentile(simulated_scores, 95), color=\"green\", linestyle=\"--\", linewidth=2,\n", " label=f\"95th percentile: {np.percentile(simulated_scores, 95):.1f}\")\n", "ax.axvline(solver.opponent_avg, color=\"orange\", linestyle=\"-\", linewidth=2,\n", " label=f\"Opponent avg: {solver.opponent_avg:.0f}\")\n", "\n", "ax.set_xlabel(\"Total Fantavoto Score\")\n", "ax.set_ylabel(\"Frequency\")\n", "ax.set_title(f\"Starting XI Score Distribution — Win Prob: {result['win_probability']:.1%}\")\n", "ax.legend()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Part 5: Transfer Market (Svincolati) Analysis\n", "\n", "Identify buy-low (undervalued) and sell-high (overvalued) players in the free agent pool using regression-to-the-mean on xG/xA divergence." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from src.optimization.transfer_analyzer import TransferAnalyzer\n", "\n", "# Simulate some players with xG/xA data\n", "np.random.seed(77)\n", "transfer_pool = pd.DataFrame([\n", " {\"name\": f\"Player_{i}\", \"team\": np.random.choice(teams_26_27), \"role\": np.random.choice([\"A\",\"C\",\"D\"]),\n", " \"actual_fv_avg\": np.round(np.random.normal(6.5, 1.0), 2),\n", " \"xg\": np.round(np.random.uniform(0, 0.6), 2),\n", " \"xa\": np.round(np.random.uniform(0, 0.3), 2),\n", " \"minutes\": np.random.randint(90, 2000),\n", " \"market_value\": np.random.randint(1, 40),\n", " \"minutes_trend\": np.random.choice([-1, 0, 1]),\n", " \"historical_fv_avg\": 6.5}\n", " for i in range(50)\n", "])\n", "\n", "analyzer = TransferAnalyzer()\n", "\n", "buy = analyzer.analyze_buy_low(transfer_pool)\n", "sell = analyzer.analyze_sell_high(transfer_pool)\n", "\n", "print(\"=== BUY-LOW TARGETS ===\")\n", "buy[['name', 'team', 'role', 'actual_fv_avg', 'expected_fv', 'fv_divergence', 'confidence']].head(8)\n", "\n", "print(\"\\n=== SELL-HIGH TARGETS ===\")\n", "sell[['name', 'team', 'role', 'actual_fv_avg', 'expected_fv', 'fv_divergence']].head(8)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Visualize buy-low vs sell-high\n", "fig, ax = plt.subplots(figsize=(10, 8))\n", "\n", "transfer_pool[\"expected_fv\"] = transfer_pool.apply(\n", " lambda r: analyzer.compute_expected_output(r[\"xg\"], r[\"xa\"], r[\"historical_fv_avg\"]), axis=1\n", ")\n", "\n", "# Color: green = buy, red = sell, blue = neutral\n", "transfer_pool[\"divergence\"] = transfer_pool[\"actual_fv_avg\"] - transfer_pool[\"expected_fv\"]\n", "conditions = [\n", " transfer_pool[\"divergence\"] < -1,\n", " transfer_pool[\"divergence\"] > 1,\n", "]\n", "choices = [\"Buy-Low\", \"Sell-High\"]\n", "transfer_pool[\"signal\"] = np.select(conditions, choices, default=\"Hold\")\n", "\n", "colors = {\"Buy-Low\": \"#2ecc71\", \"Sell-High\": \"#e74c3c\", \"Hold\": \"#95a5a6\"}\n", "\n", "for signal, group in transfer_pool.groupby(\"signal\"):\n", " ax.scatter(group[\"expected_fv\"], group[\"actual_fv_avg\"],\n", " c=colors[signal], label=signal, alpha=0.7, s=80, edgecolors=\"white\")\n", "\n", "ax.plot([3, 12], [3, 12], \"k--\", alpha=0.3, label=\"Perfect xG Efficiency\")\n", "ax.set_xlabel(\"Expected FV (from xG/xA)\")\n", "ax.set_ylabel(\"Actual FV\")\n", "ax.set_title(\"Transfer Market Analysis — Regression to the Mean\")\n", "ax.legend()\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "print(f\"Buy-Low candidates: {len(buy)}\")\n", "print(f\"Sell-High candidates: {len(sell)}\")\n", "print(f\"Hold candidates: {len(transfer_pool) - len(buy) - len(sell)}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Part 6: Tactical Briefing\n", "\n", "The full briefing generator produces a human-readable matchday summary for Telegram or Discord." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from src.bot.briefing import BriefingGenerator\n", "\n", "generator = BriefingGenerator(squad_name=\"FC Fantabeto 26/27\")\n", "\n", "# Prepare data in expected format\n", "lineup_for_briefing = [\n", " {\"name\": p.name, \"role\": p.role, \"fv_mean\": p.fv_mean,\n", " \"fv_std\": p.fv_std, \"starter_prob\": p.starter_prob}\n", " for p in lineup_players\n", "]\n", "\n", "bench_for_briefing = [\n", " {\"name\": p.name, \"fv_mean\": p.fv_mean, \"bench_reason\": \"Rotation risk\"}\n", " for p in squad_pool if p.name not in result[\"lineup\"]\n", "][:5]\n", "\n", "news_for_briefing = [\n", " \"INJURY: Dimarco suffered muscle fatigue in training (day-to-day)\",\n", " \"TACTICAL: Juventus expected to switch to 3-5-2 vs Frosinone\",\n", " \"SUSPENSION: No suspensions for Matchday 1\",\n", "]\n", "\n", "briefing = generator.generate_matchday_briefing(\n", " matchday=1,\n", " recommended_lineup=lineup_for_briefing,\n", " bench_alternatives=bench_for_briefing,\n", " captain=result[\"captain\"],\n", " expected_points=result[\"expected_points\"],\n", " win_probability=result[\"win_probability\"],\n", " news_entities=news_for_briefing,\n", ")\n", "\n", "print(briefing)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---\n", "## Summary\n", "\n", "This notebook demonstrates the full 2026/27 Fantabeto workflow:\n", "\n", "| Tool | Status | Description |\n", "|------|--------|-------------|\n", "| Auction Solver | ✅ MILP | Budget-constrained draft optimization |\n", "| Grid Auction | ✅ Game Theory | Minimax bidding strategy |\n", "| Matchday Predictions | 🔄 Mockup | GBM ensemble (real data pending) |\n", "| Lineup Optimizer | ✅ MCTS | Win-probability maximizing XI |\n", "| Transfer Analyzer | ✅ Regression | Buy-low/Sell-high signals |\n", "| Tactical Briefing | ✅ Telegram | Automated matchday communication |\n", "\n", "**Next Steps:**\n", "1. Scrape 2025/26 FBref stats with `make scrape`\n", "2. Process Fantacalcio votes via authenticated API\n", "3. Train GBM ensemble with `make train`\n", "4. Deploy GitHub Actions scheduler\n", "5. Connect Telegram bot for Friday/Sunday briefings" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11.0" } }, "nbformat": 4, "nbformat_minor": 4 }