World Cup Agent commited on
Commit
290ba8b
·
0 Parent(s):

init: World Cup prediction agent

Browse files
Files changed (14) hide show
  1. .gitignore +7 -0
  2. app.py +136 -0
  3. predict.py +297 -0
  4. reflect.py +246 -0
  5. render.yaml +9 -0
  6. requirements.txt +9 -0
  7. results.json +1 -0
  8. schedule.json +802 -0
  9. schedule_match.py +61 -0
  10. setup_schedule.py +145 -0
  11. setup_wallet.py +61 -0
  12. strategy.md +0 -0
  13. templates/index.html +318 -0
  14. wallet.py +68 -0
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .env
2
+ logs/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ venv/
7
+ .venv/
app.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """World Cup Prediction Agent — Web UI.
2
+
3
+ FastAPI app deployed on Render. Reads flat files from disk.
4
+ Start with: uvicorn app:app --host 0.0.0.0 --port $PORT
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from fastapi import FastAPI, HTTPException
13
+ from fastapi.responses import HTMLResponse
14
+ from fastapi.templating import Jinja2Templates
15
+ from starlette.requests import Request
16
+
17
+ BASE = Path(__file__).parent
18
+ templates = Jinja2Templates(directory=str(BASE / "templates"))
19
+
20
+ app = FastAPI(title="World Cup Prediction Agent")
21
+
22
+
23
+ def _load_results() -> list[dict]:
24
+ p = BASE / "results.json"
25
+ if not p.exists():
26
+ return []
27
+ try:
28
+ return json.loads(p.read_text())
29
+ except Exception:
30
+ return []
31
+
32
+
33
+ def _load_strategy() -> str:
34
+ p = BASE / "strategy.md"
35
+ return p.read_text() if p.exists() else ""
36
+
37
+
38
+ def _load_schedule() -> list[dict]:
39
+ p = BASE / "schedule.json"
40
+ if not p.exists():
41
+ return []
42
+ try:
43
+ return json.loads(p.read_text())
44
+ except Exception:
45
+ return []
46
+
47
+
48
+ def _record_stats(results: list[dict]) -> dict:
49
+ completed = [r for r in results if r.get("correct") is not None]
50
+ wins = sum(1 for r in completed if r.get("correct"))
51
+ total = len(completed)
52
+ return {
53
+ "total": total,
54
+ "wins": wins,
55
+ "losses": total - wins,
56
+ "pct": f"{wins/total*100:.0f}%" if total else "—",
57
+ }
58
+
59
+
60
+ def _upcoming(schedule: list[dict], results: list[dict]) -> list[dict]:
61
+ now = datetime.now(timezone.utc)
62
+ predicted = {(r["home"], r["away"]) for r in results}
63
+ upcoming = []
64
+ for f in schedule:
65
+ k = f.get("kickoff_utc", "")
66
+ if not k:
67
+ continue
68
+ try:
69
+ dt = datetime.fromisoformat(k.replace("Z", "+00:00"))
70
+ except ValueError:
71
+ continue
72
+ if dt > now and (f["home"], f["away"]) not in predicted:
73
+ upcoming.append({**f, "kickoff_dt": dt})
74
+ return sorted(upcoming, key=lambda x: x["kickoff_dt"])[:10]
75
+
76
+
77
+ @app.get("/", response_class=HTMLResponse)
78
+ async def dashboard(request: Request):
79
+ results = list(reversed(_load_results()))
80
+ strategy = _load_strategy()
81
+ schedule = _load_schedule()
82
+ stats = _record_stats(results)
83
+ upcoming = _upcoming(schedule, _load_results())
84
+ return templates.TemplateResponse(
85
+ "index.html",
86
+ {
87
+ "request": request,
88
+ "results": results,
89
+ "strategy": strategy,
90
+ "stats": stats,
91
+ "upcoming": upcoming,
92
+ },
93
+ )
94
+
95
+
96
+ @app.get("/match/{match_key}", response_class=HTMLResponse)
97
+ async def match_detail(request: Request, match_key: str):
98
+ home, _, away = match_key.partition("_")
99
+ home, away = home.upper(), away.upper()
100
+ results = _load_results()
101
+ entry = next(
102
+ (r for r in reversed(results) if r.get("home") == home and r.get("away") == away),
103
+ None,
104
+ )
105
+ if not entry:
106
+ raise HTTPException(status_code=404, detail=f"No prediction found for {home} vs {away}")
107
+ return templates.TemplateResponse(
108
+ "index.html",
109
+ {"request": request, "detail": entry, "results": [], "strategy": "", "stats": {}, "upcoming": []},
110
+ )
111
+
112
+
113
+ @app.get("/strategy", response_class=HTMLResponse)
114
+ async def strategy_page(request: Request):
115
+ strategy = _load_strategy()
116
+ results = _load_results()
117
+ history = [
118
+ {"match": r.get("match"), "kickoff": r.get("kickoff"), "snapshot": r.get("strategy_snapshot", "")}
119
+ for r in results
120
+ if r.get("strategy_snapshot") is not None
121
+ ]
122
+ return templates.TemplateResponse(
123
+ "index.html",
124
+ {"request": request, "strategy_page": True, "strategy": strategy, "strategy_history": history,
125
+ "results": [], "stats": {}, "upcoming": []},
126
+ )
127
+
128
+
129
+ @app.get("/api/results")
130
+ async def api_results():
131
+ return _load_results()
132
+
133
+
134
+ @app.get("/api/schedule")
135
+ async def api_schedule():
136
+ return _load_schedule()
predict.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Predict match outcome. Run 30 min before kickoff.
2
+
3
+ Usage: python predict.py HOME AWAY
4
+ Example: python predict.py NED JPN
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ import google.generativeai as genai
18
+ import requests
19
+ from dotenv import load_dotenv
20
+
21
+ import wallet
22
+
23
+ # ── Setup ──────────────────────────────────────────────────────────────────
24
+
25
+ load_dotenv()
26
+
27
+ if len(sys.argv) != 3:
28
+ print("Usage: python predict.py HOME AWAY")
29
+ sys.exit(1)
30
+
31
+ HOME = sys.argv[1].upper()
32
+ AWAY = sys.argv[2].upper()
33
+ MATCH = f"{HOME} vs {AWAY}"
34
+ WORK_DIR = Path(__file__).parent
35
+
36
+ os.makedirs(WORK_DIR / "logs", exist_ok=True)
37
+ logging.basicConfig(
38
+ level=logging.INFO,
39
+ format="%(asctime)s %(levelname)s %(message)s",
40
+ handlers=[
41
+ logging.FileHandler(WORK_DIR / "logs" / f"{HOME}_{AWAY}.log"),
42
+ logging.StreamHandler(),
43
+ ],
44
+ )
45
+ log = logging.info
46
+ err = logging.error
47
+
48
+ genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
49
+ model = genai.GenerativeModel("gemini-2.0-flash")
50
+ BUDGET = float(os.getenv("MATCH_RESEARCH_BUDGET", "0.50"))
51
+
52
+ # ── Step 1: Load state ─────────────────────────────────────────────────────
53
+
54
+ log("=== predict.py %s ===", MATCH)
55
+
56
+ strategy_path = WORK_DIR / "strategy.md"
57
+ results_path = WORK_DIR / "results.json"
58
+ schedule_path = WORK_DIR / "schedule.json"
59
+
60
+ strategy = strategy_path.read_text() if strategy_path.exists() else ""
61
+ results = json.loads(results_path.read_text()) if results_path.exists() else []
62
+ schedule = json.loads(schedule_path.read_text()) if schedule_path.exists() else []
63
+
64
+ balance = wallet.get_balance()
65
+ log("Wallet balance: $%.4f USDC", balance)
66
+
67
+ if balance < 0.10:
68
+ err("Balance $%.4f too low (min $0.10). Top up and retry.", balance)
69
+ sys.exit(1)
70
+
71
+ # Resolve kickoff from schedule
72
+ kickoff = datetime.now(timezone.utc).isoformat()
73
+ for fixture in schedule:
74
+ if fixture.get("home") == HOME and fixture.get("away") == AWAY:
75
+ kickoff = fixture.get("kickoff_utc", kickoff)
76
+ break
77
+
78
+ # ── Step 2: Fetch service directory ────────────────────────────────────────
79
+
80
+ network = os.getenv("NETWORK", "base-mainnet")
81
+ services = []
82
+ try:
83
+ resp = requests.get("https://x402-list.com/api/v1/services", timeout=10)
84
+ resp.raise_for_status()
85
+ all_services = resp.json()
86
+ services = [s for s in all_services if network in s.get("networks", [])]
87
+ log("Service directory: %d services available for %s", len(services), network)
88
+ except Exception as e:
89
+ err("Service directory fetch failed: %s — proceeding without paid data", e)
90
+
91
+ # ── Step 3: Agent plans research ───────────────────────────────────────────
92
+
93
+ recent_results = results[-3:] if results else []
94
+
95
+ plan_prompt = f"""You are a World Cup betting research agent planning data purchases.
96
+
97
+ Match: {MATCH}
98
+ Kickoff: {kickoff}
99
+ Wallet balance: ${balance:.4f} USDC
100
+ Research budget cap: ${BUDGET:.2f} USDC
101
+
102
+ Current strategy:
103
+ {strategy if strategy else "(no strategy yet — first match)"}
104
+
105
+ Recent match history:
106
+ {json.dumps(recent_results, indent=2) if recent_results else "(no history yet)"}
107
+
108
+ Available x402 data services:
109
+ {json.dumps(services, indent=2) if services else "(no services available)"}
110
+
111
+ Decide which data endpoints to purchase. Return ONLY a valid JSON array (no markdown, no explanation).
112
+ Each item must have: url (string), params (object), cost (number in USDC), reason (one sentence).
113
+ Only include endpoints from the service list above.
114
+ Total cost must not exceed ${BUDGET:.2f} USDC.
115
+ If no services are available or none are useful, return an empty array: []
116
+
117
+ Example format:
118
+ [
119
+ {{"url": "https://api.example.com/stats", "params": {{"team": "{HOME}"}}, "cost": 0.05, "reason": "Recent form data for {HOME}"}}
120
+ ]"""
121
+
122
+ research_plan = []
123
+ if services:
124
+ try:
125
+ log("Asking Gemini to plan research purchases...")
126
+ resp = model.generate_content(plan_prompt)
127
+ raw = resp.text.strip()
128
+ # Strip markdown code fences if present
129
+ raw = re.sub(r"^```[a-z]*\n?", "", raw)
130
+ raw = re.sub(r"\n?```$", "", raw)
131
+ research_plan = json.loads(raw)
132
+ log("Gemini planned %d service calls", len(research_plan))
133
+ except Exception as e:
134
+ err("Failed to parse Gemini research plan: %s — skipping paid data", e)
135
+ research_plan = []
136
+
137
+ # Validate: remove URLs not in service directory, enforce budget
138
+ valid_urls = {
139
+ endpoint["url"]
140
+ for svc in services
141
+ for endpoint in svc.get("endpoints", [])
142
+ }
143
+ research_plan = [p for p in research_plan if p.get("url") in valid_urls]
144
+
145
+ # Trim to budget (remove most expensive first)
146
+ research_plan.sort(key=lambda x: x.get("cost", 0))
147
+ total_cost = 0.0
148
+ trimmed_plan = []
149
+ for item in research_plan:
150
+ if total_cost + item.get("cost", 0) <= BUDGET:
151
+ trimmed_plan.append(item)
152
+ total_cost += item.get("cost", 0)
153
+ else:
154
+ log("Skipping %s ($%.4f) — would exceed budget", item.get("url"), item.get("cost", 0))
155
+ research_plan = trimmed_plan
156
+
157
+ log("Research plan: %d endpoints, estimated cost $%.4f", len(research_plan), total_cost)
158
+
159
+ # ── Step 4: Execute research (payments fire here) ──────────────────────────
160
+
161
+ context = []
162
+ total_spent = 0.0
163
+ services_used = []
164
+
165
+ if research_plan:
166
+ try:
167
+ session = wallet.get_x402_client()
168
+ for svc in research_plan:
169
+ url = svc["url"]
170
+ params = svc.get("params", {})
171
+ cost = svc.get("cost", 0)
172
+ try:
173
+ time.sleep(1)
174
+ r = session.get(url, params=params, timeout=15)
175
+ r.raise_for_status()
176
+ log("paid $%.4f → %s", cost, url)
177
+ data_text = r.text[:2000] # cap at 2000 chars
178
+ context.append({"source": url, "cost": cost, "data": data_text})
179
+ services_used.append({"source": url, "cost": cost, "reason": svc.get("reason", "")})
180
+ total_spent += cost
181
+ except Exception as e:
182
+ err("Failed %s: %s", url, e)
183
+ except Exception as e:
184
+ err("x402 session setup failed: %s", e)
185
+
186
+ balance_after = balance - total_spent
187
+ log("Research complete. Spent $%.4f. Estimated balance: $%.4f", total_spent, balance_after)
188
+
189
+ # ── Step 5: Make prediction ────────────────────────────────────────────────
190
+
191
+ context_text = ""
192
+ if context:
193
+ for c in context:
194
+ context_text += f"\n--- {c['source']} (${c['cost']:.4f}) ---\n{c['data']}\n"
195
+ else:
196
+ context_text = "(no paid research data purchased)"
197
+
198
+ predict_prompt = f"""You are a World Cup match predictor. Analyze the following and make a prediction.
199
+
200
+ Match: {MATCH}
201
+ Home team: {HOME}
202
+ Away team: {AWAY}
203
+ Kickoff: {kickoff}
204
+ Remaining wallet balance after research: ${balance_after:.4f} USDC
205
+
206
+ Current strategy (your accumulated learnings):
207
+ {strategy if strategy else "(no strategy yet — first match)"}
208
+
209
+ Research data purchased:
210
+ {context_text}
211
+
212
+ Recent match history and outcomes:
213
+ {json.dumps(recent_results, indent=2) if recent_results else "(no history yet)"}
214
+
215
+ Analyze the match thoroughly, then end your response with EXACTLY these 4 lines:
216
+ PICK: [home|away|draw]
217
+ CONFIDENCE: [1-10]
218
+ BET: $[amount]
219
+ REASONING: [one sentence max]"""
220
+
221
+ log("Asking Gemini for prediction...")
222
+ try:
223
+ pred_resp = model.generate_content(predict_prompt)
224
+ full_reasoning = pred_resp.text
225
+ except Exception as e:
226
+ err("Gemini prediction call failed: %s", e)
227
+ full_reasoning = ""
228
+
229
+ # Parse structured fields
230
+ def _parse(pattern, text, default):
231
+ m = re.search(pattern, text, re.IGNORECASE)
232
+ return m.group(1).strip() if m else default
233
+
234
+ pick_raw = _parse(r"PICK:\s*(\w+)", full_reasoning, "home").lower()
235
+ pick = pick_raw if pick_raw in ("home", "away", "draw") else "home"
236
+ confidence_raw = _parse(r"CONFIDENCE:\s*(\d+)", full_reasoning, "5")
237
+ confidence = max(1, min(10, int(confidence_raw)))
238
+ bet_raw = _parse(r"BET:\s*\$?([\d.]+)", full_reasoning, "0.10")
239
+ try:
240
+ bet = round(float(bet_raw), 2)
241
+ except ValueError:
242
+ bet = 0.10
243
+ reasoning = _parse(r"REASONING:\s*(.+)", full_reasoning, "Insufficient data for strong prediction")
244
+
245
+ log("Prediction: PICK=%s CONFIDENCE=%d BET=$%.2f", pick, confidence, bet)
246
+ log("Reasoning: %s", reasoning)
247
+
248
+ # ── Step 6: Save state and push to GitHub ─────────────────────────────────
249
+
250
+ entry = {
251
+ "match": MATCH,
252
+ "home": HOME,
253
+ "away": AWAY,
254
+ "kickoff": kickoff,
255
+ "strategy_snapshot": strategy,
256
+ "services_planned": research_plan,
257
+ "services_used": services_used,
258
+ "research_cost": round(total_spent, 6),
259
+ "wallet_before": round(balance, 4),
260
+ "wallet_after": round(balance_after, 4),
261
+ "prediction": {
262
+ "pick": pick,
263
+ "confidence": confidence,
264
+ "bet": bet,
265
+ "reasoning": reasoning,
266
+ },
267
+ "full_reasoning": full_reasoning,
268
+ "result": None,
269
+ "correct": None,
270
+ "evaluation": None,
271
+ "reflected": False,
272
+ }
273
+
274
+ results.append(entry)
275
+ tmp = results_path.with_suffix(".tmp")
276
+ tmp.write_text(json.dumps(results, indent=2))
277
+ tmp.replace(results_path)
278
+ log("Saved prediction to results.json")
279
+
280
+ try:
281
+ subprocess.run(["git", "add", "results.json"], cwd=WORK_DIR, check=True, capture_output=True)
282
+ subprocess.run(
283
+ ["git", "commit", "-m", f"predict: {MATCH}"],
284
+ cwd=WORK_DIR, check=True, capture_output=True,
285
+ )
286
+ subprocess.run(["git", "push"], cwd=WORK_DIR, check=True, capture_output=True)
287
+ log("Pushed results.json to GitHub")
288
+ except Exception as e:
289
+ err("Git push failed (prediction saved locally): %s", e)
290
+
291
+ print(f"\n{'='*50}")
292
+ print(f"PREDICTION: {MATCH}")
293
+ print(f" Pick: {pick.upper()} (confidence {confidence}/10)")
294
+ print(f" Reasoning: {reasoning}")
295
+ print(f" Research: ${total_spent:.4f} spent on {len(services_used)} endpoint(s)")
296
+ print(f" Balance: ${balance:.4f} → ~${balance_after:.4f}")
297
+ print(f"{'='*50}\n")
reflect.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-match reflection. Run ~2.5 hours after kickoff.
2
+
3
+ Usage: python reflect.py HOME AWAY
4
+ Example: python reflect.py NED JPN
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ import google.generativeai as genai
17
+ import requests
18
+ from dotenv import load_dotenv
19
+
20
+ # ── Setup ──────────────────────────────────────────────────────────────────
21
+
22
+ load_dotenv()
23
+
24
+ if len(sys.argv) != 3:
25
+ print("Usage: python reflect.py HOME AWAY")
26
+ sys.exit(1)
27
+
28
+ HOME = sys.argv[1].upper()
29
+ AWAY = sys.argv[2].upper()
30
+ MATCH = f"{HOME} vs {AWAY}"
31
+ WORK_DIR = Path(__file__).parent
32
+
33
+ os.makedirs(WORK_DIR / "logs", exist_ok=True)
34
+ logging.basicConfig(
35
+ level=logging.INFO,
36
+ format="%(asctime)s %(levelname)s %(message)s",
37
+ handlers=[
38
+ logging.FileHandler(WORK_DIR / "logs" / f"{HOME}_{AWAY}.log"),
39
+ logging.StreamHandler(),
40
+ ],
41
+ )
42
+ log = logging.info
43
+ err = logging.error
44
+
45
+ genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
46
+ model = genai.GenerativeModel("gemini-2.0-flash")
47
+
48
+ # ── Step 1: Get match result ───────────────────────────────────────────────
49
+
50
+ log("=== reflect.py %s ===", MATCH)
51
+
52
+ result_data = None
53
+
54
+ def _get_result_from_espn() -> dict | None:
55
+ """Try ESPN public API to find completed match score."""
56
+ try:
57
+ # Try last 2 days to account for timezone offsets
58
+ from datetime import timedelta
59
+ for delta in range(3):
60
+ day = (datetime.now(timezone.utc) - timedelta(days=delta)).strftime("%Y%m%d")
61
+ url = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard"
62
+ resp = requests.get(url, params={"dates": day}, timeout=15)
63
+ resp.raise_for_status()
64
+ data = resp.json()
65
+ for event in data.get("events", []):
66
+ comps = event.get("competitions", [{}])
67
+ if not comps:
68
+ continue
69
+ comp = comps[0]
70
+ competitors = comp.get("competitors", [])
71
+ if len(competitors) != 2:
72
+ continue
73
+ abbrevs = {c["homeAway"]: c["team"].get("abbreviation", "").upper() for c in competitors}
74
+ if abbrevs.get("home") != HOME or abbrevs.get("away") != AWAY:
75
+ continue
76
+ status = comp.get("status", {}).get("type", {})
77
+ if not status.get("completed", False):
78
+ log("Match found but not yet completed (state: %s)", status.get("state"))
79
+ return None
80
+ scores = {c["homeAway"]: int(c.get("score", 0)) for c in competitors}
81
+ home_score = scores.get("home", 0)
82
+ away_score = scores.get("away", 0)
83
+ if home_score > away_score:
84
+ winner = "home"
85
+ elif away_score > home_score:
86
+ winner = "away"
87
+ else:
88
+ winner = "draw"
89
+ log("ESPN result: %s %d-%d %s → winner: %s", HOME, home_score, away_score, AWAY, winner)
90
+ return {"home_score": home_score, "away_score": away_score, "winner": winner}
91
+ except Exception as e:
92
+ err("ESPN API failed: %s", e)
93
+ return None
94
+
95
+ result_data = _get_result_from_espn()
96
+
97
+ if result_data is None:
98
+ err("Could not fetch match result from ESPN. Try again in a few minutes.")
99
+ err("Or set the result manually in results.json and re-run.")
100
+ sys.exit(1)
101
+
102
+ # ── Step 2: Find prediction in results.json ────────────────────────────────
103
+
104
+ results_path = WORK_DIR / "results.json"
105
+ strategy_path = WORK_DIR / "strategy.md"
106
+
107
+ results = json.loads(results_path.read_text()) if results_path.exists() else []
108
+ strategy = strategy_path.read_text() if strategy_path.exists() else ""
109
+
110
+ # Find the most recent unprocessed entry for this match
111
+ entry_idx = None
112
+ for i in range(len(results) - 1, -1, -1):
113
+ e = results[i]
114
+ if e.get("home") == HOME and e.get("away") == AWAY and not e.get("reflected", False):
115
+ entry_idx = i
116
+ break
117
+
118
+ if entry_idx is None:
119
+ err("No unprocessed prediction found for %s in results.json", MATCH)
120
+ sys.exit(1)
121
+
122
+ entry = results[entry_idx]
123
+ prediction = entry.get("prediction", {})
124
+ log("Found prediction: pick=%s confidence=%d", prediction.get("pick"), prediction.get("confidence"))
125
+
126
+ # ── Step 3: Evaluator call (separate from predictor) ──────────────────────
127
+
128
+ pick = prediction.get("pick", "home")
129
+ actual_winner = result_data["winner"]
130
+ correct = (pick == actual_winner)
131
+ wallet_delta = -entry.get("research_cost", 0)
132
+
133
+ research_summary = ""
134
+ for svc in entry.get("services_used", []):
135
+ research_summary += f"\n- {svc['source']} (${svc['cost']:.4f}): {svc.get('reason', '')}"
136
+
137
+ eval_prompt = f"""You are an independent evaluator assessing a World Cup match prediction.
138
+
139
+ Match: {MATCH}
140
+ Result: {HOME} {result_data['home_score']}-{result_data['away_score']} {AWAY}
141
+ Actual winner: {actual_winner}
142
+
143
+ Prediction that was made:
144
+ - Pick: {pick}
145
+ - Confidence: {prediction.get('confidence')}/10
146
+ - Reasoning: {prediction.get('reasoning')}
147
+
148
+ Research purchased (${entry.get('research_cost', 0):.4f} total):{research_summary if research_summary else " (none purchased)"}
149
+
150
+ Outcome: prediction was {'CORRECT' if correct else 'INCORRECT'}
151
+ Research cost: ${entry.get('research_cost', 0):.4f} USDC
152
+
153
+ Evaluate honestly:
154
+ 1. Was the prediction correct? Why or why not?
155
+ 2. Which purchased research was valuable (if any)?
156
+ 3. What was the key factor that determined the outcome?
157
+ 4. What should the agent learn from this match?
158
+
159
+ Be specific and critical. This evaluation will update the agent's strategy."""
160
+
161
+ log("Asking Gemini for evaluation...")
162
+ try:
163
+ eval_resp = model.generate_content(eval_prompt)
164
+ evaluation = eval_resp.text
165
+ except Exception as e:
166
+ err("Gemini evaluation call failed: %s", e)
167
+ evaluation = f"Evaluation unavailable: {e}"
168
+
169
+ # ── Step 4: Reflector call (updates strategy) ─────────────────────────────
170
+
171
+ recent_results = results[-5:]
172
+
173
+ reflect_prompt = f"""You are updating a World Cup prediction strategy document.
174
+
175
+ Current strategy (may be empty if this is the first match):
176
+ {strategy if strategy else "(empty — this is the first match)"}
177
+
178
+ Evaluator's assessment of the latest match ({MATCH}):
179
+ {evaluation}
180
+
181
+ Recent match history for context:
182
+ {json.dumps([{
183
+ "match": r.get("match"),
184
+ "pick": r.get("prediction", {}).get("pick"),
185
+ "correct": r.get("correct"),
186
+ "reasoning": r.get("prediction", {}).get("reasoning"),
187
+ } for r in recent_results], indent=2)}
188
+
189
+ Rewrite the strategy document with EXACTLY ONE change based on this match:
190
+ - Add a new rule, modify an existing rule, or remove an outdated rule
191
+ - Keep all still-valid rules from the current strategy
192
+ - Note which x402 data endpoints have been useful vs not useful
193
+ - Include the match that informed each rule (e.g., "NED vs JPN: ...")
194
+ - Stay under 500 words total
195
+
196
+ Return ONLY the updated strategy document text. No preamble, no explanation."""
197
+
198
+ log("Asking Gemini to update strategy...")
199
+ try:
200
+ strat_resp = model.generate_content(reflect_prompt)
201
+ new_strategy = strat_resp.text.strip()
202
+ except Exception as e:
203
+ err("Gemini strategy update failed: %s", e)
204
+ new_strategy = strategy # keep current if update fails
205
+
206
+ strategy_path.write_text(new_strategy)
207
+ log("Updated strategy.md")
208
+
209
+ # ── Step 5: Update results.json and push to GitHub ─────────────────────────
210
+
211
+ results[entry_idx].update({
212
+ "result": result_data,
213
+ "correct": correct,
214
+ "evaluation": evaluation,
215
+ "reflected": True,
216
+ "reflected_at": datetime.now(timezone.utc).isoformat(),
217
+ })
218
+
219
+ tmp = results_path.with_suffix(".tmp")
220
+ tmp.write_text(json.dumps(results, indent=2))
221
+ tmp.replace(results_path)
222
+ log("Updated results.json")
223
+
224
+ try:
225
+ subprocess.run(
226
+ ["git", "add", "results.json", "strategy.md"],
227
+ cwd=WORK_DIR, check=True, capture_output=True,
228
+ )
229
+ subprocess.run(
230
+ ["git", "commit", "-m", f"reflect: {MATCH}"],
231
+ cwd=WORK_DIR, check=True, capture_output=True,
232
+ )
233
+ subprocess.run(["git", "push"], cwd=WORK_DIR, check=True, capture_output=True)
234
+ log("Pushed to GitHub — Render will redeploy")
235
+ except Exception as e:
236
+ err("Git push failed (files saved locally): %s", e)
237
+
238
+ outcome_str = "✓ CORRECT" if correct else "✗ INCORRECT"
239
+ print(f"\n{'='*50}")
240
+ print(f"REFLECTION: {MATCH}")
241
+ print(f" Result: {HOME} {result_data['home_score']}-{result_data['away_score']} {AWAY}")
242
+ print(f" Predicted: {pick.upper()} → {outcome_str}")
243
+ print(f" Research: ${entry.get('research_cost', 0):.4f} spent")
244
+ print(f"\nStrategy update (first 200 chars):")
245
+ print(f" {new_strategy[:200]}...")
246
+ print(f"{'='*50}\n")
render.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: worldcup-predictions
4
+ env: python
5
+ buildCommand: pip install -r requirements.txt
6
+ startCommand: uvicorn app:app --host 0.0.0.0 --port $PORT
7
+ envVars:
8
+ - key: PORT
9
+ value: 10000
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ google-generativeai
2
+ eth-account
3
+ x402[requests,evm]
4
+ web3
5
+ requests
6
+ python-dotenv
7
+ fastapi
8
+ uvicorn[standard]
9
+ jinja2
results.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
schedule.json ADDED
@@ -0,0 +1,802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "home": "MEX",
4
+ "away": "RSA",
5
+ "home_name": "Mexico",
6
+ "away_name": "South Africa",
7
+ "kickoff_utc": "2026-06-11T19:00Z",
8
+ "stage": "Group Stage"
9
+ },
10
+ {
11
+ "home": "KOR",
12
+ "away": "CZE",
13
+ "home_name": "South Korea",
14
+ "away_name": "Czechia",
15
+ "kickoff_utc": "2026-06-12T02:00Z",
16
+ "stage": "Group Stage"
17
+ },
18
+ {
19
+ "home": "CAN",
20
+ "away": "BIH",
21
+ "home_name": "Canada",
22
+ "away_name": "Bosnia-Herzegovina",
23
+ "kickoff_utc": "2026-06-12T19:00Z",
24
+ "stage": "Group Stage"
25
+ },
26
+ {
27
+ "home": "USA",
28
+ "away": "PAR",
29
+ "home_name": "United States",
30
+ "away_name": "Paraguay",
31
+ "kickoff_utc": "2026-06-13T01:00Z",
32
+ "stage": "Group Stage"
33
+ },
34
+ {
35
+ "home": "QAT",
36
+ "away": "SUI",
37
+ "home_name": "Qatar",
38
+ "away_name": "Switzerland",
39
+ "kickoff_utc": "2026-06-13T19:00Z",
40
+ "stage": "Group Stage"
41
+ },
42
+ {
43
+ "home": "BRA",
44
+ "away": "MAR",
45
+ "home_name": "Brazil",
46
+ "away_name": "Morocco",
47
+ "kickoff_utc": "2026-06-13T22:00Z",
48
+ "stage": "Group Stage"
49
+ },
50
+ {
51
+ "home": "HAI",
52
+ "away": "SCO",
53
+ "home_name": "Haiti",
54
+ "away_name": "Scotland",
55
+ "kickoff_utc": "2026-06-14T01:00Z",
56
+ "stage": "Group Stage"
57
+ },
58
+ {
59
+ "home": "AUS",
60
+ "away": "TUR",
61
+ "home_name": "Australia",
62
+ "away_name": "T\u00fcrkiye",
63
+ "kickoff_utc": "2026-06-14T04:00Z",
64
+ "stage": "Group Stage"
65
+ },
66
+ {
67
+ "home": "GER",
68
+ "away": "CUW",
69
+ "home_name": "Germany",
70
+ "away_name": "Cura\u00e7ao",
71
+ "kickoff_utc": "2026-06-14T17:00Z",
72
+ "stage": "Group Stage"
73
+ },
74
+ {
75
+ "home": "NED",
76
+ "away": "JPN",
77
+ "home_name": "Netherlands",
78
+ "away_name": "Japan",
79
+ "kickoff_utc": "2026-06-14T20:00Z",
80
+ "stage": "Group Stage"
81
+ },
82
+ {
83
+ "home": "CIV",
84
+ "away": "ECU",
85
+ "home_name": "Ivory Coast",
86
+ "away_name": "Ecuador",
87
+ "kickoff_utc": "2026-06-14T23:00Z",
88
+ "stage": "Group Stage"
89
+ },
90
+ {
91
+ "home": "SWE",
92
+ "away": "TUN",
93
+ "home_name": "Sweden",
94
+ "away_name": "Tunisia",
95
+ "kickoff_utc": "2026-06-15T02:00Z",
96
+ "stage": "Group Stage"
97
+ },
98
+ {
99
+ "home": "ESP",
100
+ "away": "CPV",
101
+ "home_name": "Spain",
102
+ "away_name": "Cape Verde",
103
+ "kickoff_utc": "2026-06-15T16:00Z",
104
+ "stage": "Group Stage"
105
+ },
106
+ {
107
+ "home": "BEL",
108
+ "away": "EGY",
109
+ "home_name": "Belgium",
110
+ "away_name": "Egypt",
111
+ "kickoff_utc": "2026-06-15T19:00Z",
112
+ "stage": "Group Stage"
113
+ },
114
+ {
115
+ "home": "KSA",
116
+ "away": "URU",
117
+ "home_name": "Saudi Arabia",
118
+ "away_name": "Uruguay",
119
+ "kickoff_utc": "2026-06-15T22:00Z",
120
+ "stage": "Group Stage"
121
+ },
122
+ {
123
+ "home": "IRN",
124
+ "away": "NZL",
125
+ "home_name": "Iran",
126
+ "away_name": "New Zealand",
127
+ "kickoff_utc": "2026-06-16T01:00Z",
128
+ "stage": "Group Stage"
129
+ },
130
+ {
131
+ "home": "FRA",
132
+ "away": "SEN",
133
+ "home_name": "France",
134
+ "away_name": "Senegal",
135
+ "kickoff_utc": "2026-06-16T19:00Z",
136
+ "stage": "Group Stage"
137
+ },
138
+ {
139
+ "home": "IRQ",
140
+ "away": "NOR",
141
+ "home_name": "Iraq",
142
+ "away_name": "Norway",
143
+ "kickoff_utc": "2026-06-16T22:00Z",
144
+ "stage": "Group Stage"
145
+ },
146
+ {
147
+ "home": "ARG",
148
+ "away": "ALG",
149
+ "home_name": "Argentina",
150
+ "away_name": "Algeria",
151
+ "kickoff_utc": "2026-06-17T01:00Z",
152
+ "stage": "Group Stage"
153
+ },
154
+ {
155
+ "home": "AUT",
156
+ "away": "JOR",
157
+ "home_name": "Austria",
158
+ "away_name": "Jordan",
159
+ "kickoff_utc": "2026-06-17T04:00Z",
160
+ "stage": "Group Stage"
161
+ },
162
+ {
163
+ "home": "POR",
164
+ "away": "COD",
165
+ "home_name": "Portugal",
166
+ "away_name": "Congo DR",
167
+ "kickoff_utc": "2026-06-17T17:00Z",
168
+ "stage": "Group Stage"
169
+ },
170
+ {
171
+ "home": "ENG",
172
+ "away": "CRO",
173
+ "home_name": "England",
174
+ "away_name": "Croatia",
175
+ "kickoff_utc": "2026-06-17T20:00Z",
176
+ "stage": "Group Stage"
177
+ },
178
+ {
179
+ "home": "GHA",
180
+ "away": "PAN",
181
+ "home_name": "Ghana",
182
+ "away_name": "Panama",
183
+ "kickoff_utc": "2026-06-17T23:00Z",
184
+ "stage": "Group Stage"
185
+ },
186
+ {
187
+ "home": "UZB",
188
+ "away": "COL",
189
+ "home_name": "Uzbekistan",
190
+ "away_name": "Colombia",
191
+ "kickoff_utc": "2026-06-18T02:00Z",
192
+ "stage": "Group Stage"
193
+ },
194
+ {
195
+ "home": "CZE",
196
+ "away": "RSA",
197
+ "home_name": "Czechia",
198
+ "away_name": "South Africa",
199
+ "kickoff_utc": "2026-06-18T16:00Z",
200
+ "stage": "Group Stage"
201
+ },
202
+ {
203
+ "home": "SUI",
204
+ "away": "BIH",
205
+ "home_name": "Switzerland",
206
+ "away_name": "Bosnia-Herzegovina",
207
+ "kickoff_utc": "2026-06-18T19:00Z",
208
+ "stage": "Group Stage"
209
+ },
210
+ {
211
+ "home": "CAN",
212
+ "away": "QAT",
213
+ "home_name": "Canada",
214
+ "away_name": "Qatar",
215
+ "kickoff_utc": "2026-06-18T22:00Z",
216
+ "stage": "Group Stage"
217
+ },
218
+ {
219
+ "home": "MEX",
220
+ "away": "KOR",
221
+ "home_name": "Mexico",
222
+ "away_name": "South Korea",
223
+ "kickoff_utc": "2026-06-19T01:00Z",
224
+ "stage": "Group Stage"
225
+ },
226
+ {
227
+ "home": "USA",
228
+ "away": "AUS",
229
+ "home_name": "United States",
230
+ "away_name": "Australia",
231
+ "kickoff_utc": "2026-06-19T19:00Z",
232
+ "stage": "Group Stage"
233
+ },
234
+ {
235
+ "home": "SCO",
236
+ "away": "MAR",
237
+ "home_name": "Scotland",
238
+ "away_name": "Morocco",
239
+ "kickoff_utc": "2026-06-19T22:00Z",
240
+ "stage": "Group Stage"
241
+ },
242
+ {
243
+ "home": "BRA",
244
+ "away": "HAI",
245
+ "home_name": "Brazil",
246
+ "away_name": "Haiti",
247
+ "kickoff_utc": "2026-06-20T00:30Z",
248
+ "stage": "Group Stage"
249
+ },
250
+ {
251
+ "home": "TUR",
252
+ "away": "PAR",
253
+ "home_name": "T\u00fcrkiye",
254
+ "away_name": "Paraguay",
255
+ "kickoff_utc": "2026-06-20T03:00Z",
256
+ "stage": "Group Stage"
257
+ },
258
+ {
259
+ "home": "NED",
260
+ "away": "SWE",
261
+ "home_name": "Netherlands",
262
+ "away_name": "Sweden",
263
+ "kickoff_utc": "2026-06-20T17:00Z",
264
+ "stage": "Group Stage"
265
+ },
266
+ {
267
+ "home": "GER",
268
+ "away": "CIV",
269
+ "home_name": "Germany",
270
+ "away_name": "Ivory Coast",
271
+ "kickoff_utc": "2026-06-20T20:00Z",
272
+ "stage": "Group Stage"
273
+ },
274
+ {
275
+ "home": "ECU",
276
+ "away": "CUW",
277
+ "home_name": "Ecuador",
278
+ "away_name": "Cura\u00e7ao",
279
+ "kickoff_utc": "2026-06-21T00:00Z",
280
+ "stage": "Group Stage"
281
+ },
282
+ {
283
+ "home": "TUN",
284
+ "away": "JPN",
285
+ "home_name": "Tunisia",
286
+ "away_name": "Japan",
287
+ "kickoff_utc": "2026-06-21T04:00Z",
288
+ "stage": "Group Stage"
289
+ },
290
+ {
291
+ "home": "ESP",
292
+ "away": "KSA",
293
+ "home_name": "Spain",
294
+ "away_name": "Saudi Arabia",
295
+ "kickoff_utc": "2026-06-21T16:00Z",
296
+ "stage": "Group Stage"
297
+ },
298
+ {
299
+ "home": "BEL",
300
+ "away": "IRN",
301
+ "home_name": "Belgium",
302
+ "away_name": "Iran",
303
+ "kickoff_utc": "2026-06-21T19:00Z",
304
+ "stage": "Group Stage"
305
+ },
306
+ {
307
+ "home": "URU",
308
+ "away": "CPV",
309
+ "home_name": "Uruguay",
310
+ "away_name": "Cape Verde",
311
+ "kickoff_utc": "2026-06-21T22:00Z",
312
+ "stage": "Group Stage"
313
+ },
314
+ {
315
+ "home": "NZL",
316
+ "away": "EGY",
317
+ "home_name": "New Zealand",
318
+ "away_name": "Egypt",
319
+ "kickoff_utc": "2026-06-22T01:00Z",
320
+ "stage": "Group Stage"
321
+ },
322
+ {
323
+ "home": "ARG",
324
+ "away": "AUT",
325
+ "home_name": "Argentina",
326
+ "away_name": "Austria",
327
+ "kickoff_utc": "2026-06-22T17:00Z",
328
+ "stage": "Group Stage"
329
+ },
330
+ {
331
+ "home": "FRA",
332
+ "away": "IRQ",
333
+ "home_name": "France",
334
+ "away_name": "Iraq",
335
+ "kickoff_utc": "2026-06-22T21:00Z",
336
+ "stage": "Group Stage"
337
+ },
338
+ {
339
+ "home": "NOR",
340
+ "away": "SEN",
341
+ "home_name": "Norway",
342
+ "away_name": "Senegal",
343
+ "kickoff_utc": "2026-06-23T00:00Z",
344
+ "stage": "Group Stage"
345
+ },
346
+ {
347
+ "home": "JOR",
348
+ "away": "ALG",
349
+ "home_name": "Jordan",
350
+ "away_name": "Algeria",
351
+ "kickoff_utc": "2026-06-23T03:00Z",
352
+ "stage": "Group Stage"
353
+ },
354
+ {
355
+ "home": "POR",
356
+ "away": "UZB",
357
+ "home_name": "Portugal",
358
+ "away_name": "Uzbekistan",
359
+ "kickoff_utc": "2026-06-23T17:00Z",
360
+ "stage": "Group Stage"
361
+ },
362
+ {
363
+ "home": "ENG",
364
+ "away": "GHA",
365
+ "home_name": "England",
366
+ "away_name": "Ghana",
367
+ "kickoff_utc": "2026-06-23T20:00Z",
368
+ "stage": "Group Stage"
369
+ },
370
+ {
371
+ "home": "PAN",
372
+ "away": "CRO",
373
+ "home_name": "Panama",
374
+ "away_name": "Croatia",
375
+ "kickoff_utc": "2026-06-23T23:00Z",
376
+ "stage": "Group Stage"
377
+ },
378
+ {
379
+ "home": "COL",
380
+ "away": "COD",
381
+ "home_name": "Colombia",
382
+ "away_name": "Congo DR",
383
+ "kickoff_utc": "2026-06-24T02:00Z",
384
+ "stage": "Group Stage"
385
+ },
386
+ {
387
+ "home": "BIH",
388
+ "away": "QAT",
389
+ "home_name": "Bosnia-Herzegovina",
390
+ "away_name": "Qatar",
391
+ "kickoff_utc": "2026-06-24T19:00Z",
392
+ "stage": "Group Stage"
393
+ },
394
+ {
395
+ "home": "SUI",
396
+ "away": "CAN",
397
+ "home_name": "Switzerland",
398
+ "away_name": "Canada",
399
+ "kickoff_utc": "2026-06-24T19:00Z",
400
+ "stage": "Group Stage"
401
+ },
402
+ {
403
+ "home": "MAR",
404
+ "away": "HAI",
405
+ "home_name": "Morocco",
406
+ "away_name": "Haiti",
407
+ "kickoff_utc": "2026-06-24T22:00Z",
408
+ "stage": "Group Stage"
409
+ },
410
+ {
411
+ "home": "SCO",
412
+ "away": "BRA",
413
+ "home_name": "Scotland",
414
+ "away_name": "Brazil",
415
+ "kickoff_utc": "2026-06-24T22:00Z",
416
+ "stage": "Group Stage"
417
+ },
418
+ {
419
+ "home": "CZE",
420
+ "away": "MEX",
421
+ "home_name": "Czechia",
422
+ "away_name": "Mexico",
423
+ "kickoff_utc": "2026-06-25T01:00Z",
424
+ "stage": "Group Stage"
425
+ },
426
+ {
427
+ "home": "RSA",
428
+ "away": "KOR",
429
+ "home_name": "South Africa",
430
+ "away_name": "South Korea",
431
+ "kickoff_utc": "2026-06-25T01:00Z",
432
+ "stage": "Group Stage"
433
+ },
434
+ {
435
+ "home": "CUW",
436
+ "away": "CIV",
437
+ "home_name": "Cura\u00e7ao",
438
+ "away_name": "Ivory Coast",
439
+ "kickoff_utc": "2026-06-25T20:00Z",
440
+ "stage": "Group Stage"
441
+ },
442
+ {
443
+ "home": "ECU",
444
+ "away": "GER",
445
+ "home_name": "Ecuador",
446
+ "away_name": "Germany",
447
+ "kickoff_utc": "2026-06-25T20:00Z",
448
+ "stage": "Group Stage"
449
+ },
450
+ {
451
+ "home": "JPN",
452
+ "away": "SWE",
453
+ "home_name": "Japan",
454
+ "away_name": "Sweden",
455
+ "kickoff_utc": "2026-06-25T23:00Z",
456
+ "stage": "Group Stage"
457
+ },
458
+ {
459
+ "home": "TUN",
460
+ "away": "NED",
461
+ "home_name": "Tunisia",
462
+ "away_name": "Netherlands",
463
+ "kickoff_utc": "2026-06-25T23:00Z",
464
+ "stage": "Group Stage"
465
+ },
466
+ {
467
+ "home": "PAR",
468
+ "away": "AUS",
469
+ "home_name": "Paraguay",
470
+ "away_name": "Australia",
471
+ "kickoff_utc": "2026-06-26T02:00Z",
472
+ "stage": "Group Stage"
473
+ },
474
+ {
475
+ "home": "TUR",
476
+ "away": "USA",
477
+ "home_name": "T\u00fcrkiye",
478
+ "away_name": "United States",
479
+ "kickoff_utc": "2026-06-26T02:00Z",
480
+ "stage": "Group Stage"
481
+ },
482
+ {
483
+ "home": "NOR",
484
+ "away": "FRA",
485
+ "home_name": "Norway",
486
+ "away_name": "France",
487
+ "kickoff_utc": "2026-06-26T19:00Z",
488
+ "stage": "Group Stage"
489
+ },
490
+ {
491
+ "home": "SEN",
492
+ "away": "IRQ",
493
+ "home_name": "Senegal",
494
+ "away_name": "Iraq",
495
+ "kickoff_utc": "2026-06-26T19:00Z",
496
+ "stage": "Group Stage"
497
+ },
498
+ {
499
+ "home": "CPV",
500
+ "away": "KSA",
501
+ "home_name": "Cape Verde",
502
+ "away_name": "Saudi Arabia",
503
+ "kickoff_utc": "2026-06-27T00:00Z",
504
+ "stage": "Group Stage"
505
+ },
506
+ {
507
+ "home": "URU",
508
+ "away": "ESP",
509
+ "home_name": "Uruguay",
510
+ "away_name": "Spain",
511
+ "kickoff_utc": "2026-06-27T00:00Z",
512
+ "stage": "Group Stage"
513
+ },
514
+ {
515
+ "home": "EGY",
516
+ "away": "IRN",
517
+ "home_name": "Egypt",
518
+ "away_name": "Iran",
519
+ "kickoff_utc": "2026-06-27T03:00Z",
520
+ "stage": "Group Stage"
521
+ },
522
+ {
523
+ "home": "NZL",
524
+ "away": "BEL",
525
+ "home_name": "New Zealand",
526
+ "away_name": "Belgium",
527
+ "kickoff_utc": "2026-06-27T03:00Z",
528
+ "stage": "Group Stage"
529
+ },
530
+ {
531
+ "home": "CRO",
532
+ "away": "GHA",
533
+ "home_name": "Croatia",
534
+ "away_name": "Ghana",
535
+ "kickoff_utc": "2026-06-27T21:00Z",
536
+ "stage": "Group Stage"
537
+ },
538
+ {
539
+ "home": "PAN",
540
+ "away": "ENG",
541
+ "home_name": "Panama",
542
+ "away_name": "England",
543
+ "kickoff_utc": "2026-06-27T21:00Z",
544
+ "stage": "Group Stage"
545
+ },
546
+ {
547
+ "home": "COL",
548
+ "away": "POR",
549
+ "home_name": "Colombia",
550
+ "away_name": "Portugal",
551
+ "kickoff_utc": "2026-06-27T23:30Z",
552
+ "stage": "Group Stage"
553
+ },
554
+ {
555
+ "home": "COD",
556
+ "away": "UZB",
557
+ "home_name": "Congo DR",
558
+ "away_name": "Uzbekistan",
559
+ "kickoff_utc": "2026-06-27T23:30Z",
560
+ "stage": "Group Stage"
561
+ },
562
+ {
563
+ "home": "ALG",
564
+ "away": "AUT",
565
+ "home_name": "Algeria",
566
+ "away_name": "Austria",
567
+ "kickoff_utc": "2026-06-28T02:00Z",
568
+ "stage": "Group Stage"
569
+ },
570
+ {
571
+ "home": "JOR",
572
+ "away": "ARG",
573
+ "home_name": "Jordan",
574
+ "away_name": "Argentina",
575
+ "kickoff_utc": "2026-06-28T02:00Z",
576
+ "stage": "Group Stage"
577
+ },
578
+ {
579
+ "home": "2A",
580
+ "away": "2B",
581
+ "home_name": "Group A 2nd Place",
582
+ "away_name": "Group B 2nd Place",
583
+ "kickoff_utc": "2026-06-28T19:00Z",
584
+ "stage": "Round Of 32"
585
+ },
586
+ {
587
+ "home": "1C",
588
+ "away": "2F",
589
+ "home_name": "Group C Winner",
590
+ "away_name": "Group F 2nd Place",
591
+ "kickoff_utc": "2026-06-29T17:00Z",
592
+ "stage": "Round Of 32"
593
+ },
594
+ {
595
+ "home": "1E",
596
+ "away": "3RD",
597
+ "home_name": "Group E Winner",
598
+ "away_name": "Third Place Group A/B/C/D/F",
599
+ "kickoff_utc": "2026-06-29T20:30Z",
600
+ "stage": "Round Of 32"
601
+ },
602
+ {
603
+ "home": "1F",
604
+ "away": "2C",
605
+ "home_name": "Group F Winner",
606
+ "away_name": "Group C 2nd Place",
607
+ "kickoff_utc": "2026-06-30T01:00Z",
608
+ "stage": "Round Of 32"
609
+ },
610
+ {
611
+ "home": "2E",
612
+ "away": "2I",
613
+ "home_name": "Group E 2nd Place",
614
+ "away_name": "Group I 2nd Place",
615
+ "kickoff_utc": "2026-06-30T17:00Z",
616
+ "stage": "Round Of 32"
617
+ },
618
+ {
619
+ "home": "1I",
620
+ "away": "3RD",
621
+ "home_name": "Group I Winner",
622
+ "away_name": "Third Place Group C/D/F/G/H",
623
+ "kickoff_utc": "2026-06-30T21:00Z",
624
+ "stage": "Round Of 32"
625
+ },
626
+ {
627
+ "home": "1A",
628
+ "away": "3RD",
629
+ "home_name": "Group A Winner",
630
+ "away_name": "Third Place Group C/E/F/H/I",
631
+ "kickoff_utc": "2026-07-01T01:00Z",
632
+ "stage": "Round Of 32"
633
+ },
634
+ {
635
+ "home": "1L",
636
+ "away": "3RD",
637
+ "home_name": "Group L Winner",
638
+ "away_name": "Third Place Group E/H/I/J/K",
639
+ "kickoff_utc": "2026-07-01T16:00Z",
640
+ "stage": "Round Of 32"
641
+ },
642
+ {
643
+ "home": "1G",
644
+ "away": "3RD",
645
+ "home_name": "Group G Winner",
646
+ "away_name": "Third Place Group A/E/H/I/J",
647
+ "kickoff_utc": "2026-07-01T20:00Z",
648
+ "stage": "Round Of 32"
649
+ },
650
+ {
651
+ "home": "1D",
652
+ "away": "3RD",
653
+ "home_name": "Group D Winner",
654
+ "away_name": "Third Place Group B/E/F/I/J",
655
+ "kickoff_utc": "2026-07-02T00:00Z",
656
+ "stage": "Round Of 32"
657
+ },
658
+ {
659
+ "home": "1H",
660
+ "away": "2J",
661
+ "home_name": "Group H Winner",
662
+ "away_name": "Group J 2nd Place",
663
+ "kickoff_utc": "2026-07-02T19:00Z",
664
+ "stage": "Round Of 32"
665
+ },
666
+ {
667
+ "home": "2K",
668
+ "away": "2L",
669
+ "home_name": "Group K 2nd Place",
670
+ "away_name": "Group L 2nd Place",
671
+ "kickoff_utc": "2026-07-02T23:00Z",
672
+ "stage": "Round Of 32"
673
+ },
674
+ {
675
+ "home": "1B",
676
+ "away": "3RD",
677
+ "home_name": "Group B Winner",
678
+ "away_name": "Third Place Group E/F/G/I/J",
679
+ "kickoff_utc": "2026-07-03T03:00Z",
680
+ "stage": "Round Of 32"
681
+ },
682
+ {
683
+ "home": "2D",
684
+ "away": "2G",
685
+ "home_name": "Group D 2nd Place",
686
+ "away_name": "Group G 2nd Place",
687
+ "kickoff_utc": "2026-07-03T18:00Z",
688
+ "stage": "Round Of 32"
689
+ },
690
+ {
691
+ "home": "1J",
692
+ "away": "2H",
693
+ "home_name": "Group J Winner",
694
+ "away_name": "Group H 2nd Place",
695
+ "kickoff_utc": "2026-07-03T22:00Z",
696
+ "stage": "Round Of 32"
697
+ },
698
+ {
699
+ "home": "1K",
700
+ "away": "3RD",
701
+ "home_name": "Group K Winner",
702
+ "away_name": "Third Place Group D/E/I/J/L",
703
+ "kickoff_utc": "2026-07-04T01:30Z",
704
+ "stage": "Round Of 32"
705
+ },
706
+ {
707
+ "home": "RD32",
708
+ "away": "RD32",
709
+ "home_name": "Round of 32 1 Winner",
710
+ "away_name": "Round of 32 3 Winner",
711
+ "kickoff_utc": "2026-07-04T17:00Z",
712
+ "stage": "Round Of 16"
713
+ },
714
+ {
715
+ "home": "RD32",
716
+ "away": "RD32",
717
+ "home_name": "Round of 32 2 Winner",
718
+ "away_name": "Round of 32 5 Winner",
719
+ "kickoff_utc": "2026-07-04T21:00Z",
720
+ "stage": "Round Of 16"
721
+ },
722
+ {
723
+ "home": "RD32",
724
+ "away": "RD32",
725
+ "home_name": "Round of 32 4 Winner",
726
+ "away_name": "Round of 32 6 Winner",
727
+ "kickoff_utc": "2026-07-05T20:00Z",
728
+ "stage": "Round Of 16"
729
+ },
730
+ {
731
+ "home": "RD32",
732
+ "away": "RD32",
733
+ "home_name": "Round of 32 7 Winner",
734
+ "away_name": "Round of 32 8 Winner",
735
+ "kickoff_utc": "2026-07-06T00:00Z",
736
+ "stage": "Round Of 16"
737
+ },
738
+ {
739
+ "home": "RD32",
740
+ "away": "RD32",
741
+ "home_name": "Round of 32 11 Winner",
742
+ "away_name": "Round of 32 12 Winner",
743
+ "kickoff_utc": "2026-07-06T19:00Z",
744
+ "stage": "Round Of 16"
745
+ },
746
+ {
747
+ "home": "RD32",
748
+ "away": "RD32",
749
+ "home_name": "Round of 32 9 Winner",
750
+ "away_name": "Round of 32 10 Winner",
751
+ "kickoff_utc": "2026-07-07T00:00Z",
752
+ "stage": "Round Of 16"
753
+ },
754
+ {
755
+ "home": "RD32",
756
+ "away": "RD32",
757
+ "home_name": "Round of 32 14 Winner",
758
+ "away_name": "Round of 32 16 Winner",
759
+ "kickoff_utc": "2026-07-07T16:00Z",
760
+ "stage": "Round Of 16"
761
+ },
762
+ {
763
+ "home": "RD32",
764
+ "away": "RD32",
765
+ "home_name": "Round of 32 13 Winner",
766
+ "away_name": "Round of 32 15 Winner",
767
+ "kickoff_utc": "2026-07-07T20:00Z",
768
+ "stage": "Round Of 16"
769
+ },
770
+ {
771
+ "home": "RD16 W1",
772
+ "away": "RD16 W2",
773
+ "home_name": "Round of 16 1 Winner",
774
+ "away_name": "Round of 16 2 Winner",
775
+ "kickoff_utc": "2026-07-09T20:00Z",
776
+ "stage": "Quarterfinals"
777
+ },
778
+ {
779
+ "home": "RD16 W5",
780
+ "away": "RD16 W6",
781
+ "home_name": "Round of 16 5 Winner",
782
+ "away_name": "Round of 16 6 Winner",
783
+ "kickoff_utc": "2026-07-10T19:00Z",
784
+ "stage": "Quarterfinals"
785
+ },
786
+ {
787
+ "home": "RD16 W3",
788
+ "away": "RD16 W4",
789
+ "home_name": "Round of 16 3 Winner",
790
+ "away_name": "Round of 16 4 Winner",
791
+ "kickoff_utc": "2026-07-11T21:00Z",
792
+ "stage": "Quarterfinals"
793
+ },
794
+ {
795
+ "home": "RD16 W7",
796
+ "away": "RD16 W8",
797
+ "home_name": "Round of 16 7 Winner",
798
+ "away_name": "Round of 16 8 Winner",
799
+ "kickoff_utc": "2026-07-12T01:00Z",
800
+ "stage": "Quarterfinals"
801
+ }
802
+ ]
schedule_match.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helper: write predict + reflect cron entries for a single match.
2
+
3
+ Imported by setup_schedule.py. Can also be run directly:
4
+ python schedule_match.py NED JPN 2026-06-14T20:00:00Z
5
+ """
6
+
7
+ import subprocess
8
+ import sys
9
+ from datetime import datetime, timedelta, timezone
10
+
11
+
12
+ def make_cron_lines(home: str, away: str, kickoff_utc: str) -> tuple[str, str]:
13
+ """Return (predict_cron_line, reflect_cron_line) for the match."""
14
+ dt = datetime.fromisoformat(kickoff_utc.replace("Z", "+00:00")).astimezone(timezone.utc)
15
+
16
+ predict_dt = dt - timedelta(minutes=30)
17
+ reflect_dt = dt + timedelta(minutes=150) # 2.5 hours after kickoff
18
+
19
+ def cron_time(d: datetime) -> str:
20
+ return f"{d.minute} {d.hour} {d.day} {d.month} *"
21
+
22
+ log_file = f"logs/{home}_{away}.log"
23
+ base_cmd = f"cd /home/ubuntu/worldcup && source .env && /home/ubuntu/worldcup/venv/bin/python"
24
+
25
+ predict_line = (
26
+ f"# {home} vs {away} — predict (30 min before kickoff {dt.strftime('%Y-%m-%d %H:%M')} UTC)\n"
27
+ f"{cron_time(predict_dt)} {base_cmd} predict.py {home} {away} >> {log_file} 2>&1"
28
+ )
29
+ reflect_line = (
30
+ f"# {home} vs {away} — reflect (2.5h after kickoff)\n"
31
+ f"{cron_time(reflect_dt)} {base_cmd} reflect.py {home} {away} >> {log_file} 2>&1"
32
+ )
33
+ return predict_line, reflect_line
34
+
35
+
36
+ def add_cron_entries(home: str, away: str, kickoff_utc: str) -> tuple[str, str]:
37
+ """Add cron entries if not already present. Returns the two lines added."""
38
+ predict_line, reflect_line = make_cron_lines(home, away, kickoff_utc)
39
+
40
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
41
+ existing = result.stdout if result.returncode == 0 else ""
42
+
43
+ skip_marker = f"predict.py {home} {away}"
44
+ if skip_marker in existing:
45
+ return predict_line, reflect_line # already scheduled, skip
46
+
47
+ new_crontab = existing.rstrip() + "\n\n" + predict_line + "\n" + reflect_line + "\n"
48
+ subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
49
+ return predict_line, reflect_line
50
+
51
+
52
+ if __name__ == "__main__":
53
+ if len(sys.argv) != 4:
54
+ print("Usage: python schedule_match.py HOME AWAY KICKOFF_UTC")
55
+ print("Example: python schedule_match.py NED JPN 2026-06-14T20:00:00Z")
56
+ sys.exit(1)
57
+ home, away, kickoff = sys.argv[1].upper(), sys.argv[2].upper(), sys.argv[3]
58
+ p, r = add_cron_entries(home, away, kickoff)
59
+ print("Added cron entries:")
60
+ print(p)
61
+ print(r)
setup_schedule.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch World Cup 2026 fixture list and write all cron entries at once.
2
+
3
+ Run once at the start of the tournament:
4
+ python setup_schedule.py # fetch + schedule + push schedule.json
5
+ python setup_schedule.py --dry-run # print only, no cron writes
6
+ """
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import requests
15
+
16
+ import schedule_match
17
+
18
+ WORK_DIR = Path(__file__).parent
19
+
20
+ # World Cup 2026 date range
21
+ WC_START = "20260611"
22
+ WC_END = "20260719"
23
+
24
+ ESPN_URL = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard"
25
+
26
+
27
+ def fetch_fixtures() -> list[dict]:
28
+ """Fetch all WC 2026 fixtures from ESPN public API."""
29
+ print(f"Fetching fixtures from ESPN ({WC_START}–{WC_END})...")
30
+ resp = requests.get(ESPN_URL, params={"dates": f"{WC_START}-{WC_END}"}, timeout=30)
31
+ resp.raise_for_status()
32
+ data = resp.json()
33
+
34
+ fixtures = []
35
+ for event in data.get("events", []):
36
+ comps = event.get("competitions", [{}])
37
+ if not comps:
38
+ continue
39
+ comp = comps[0]
40
+ competitors = comp.get("competitors", [])
41
+ if len(competitors) != 2:
42
+ continue
43
+
44
+ home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
45
+ away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
46
+ if not home_team or not away_team:
47
+ continue
48
+
49
+ kickoff_utc = event.get("date", "") # ISO format from ESPN
50
+ stage = (
51
+ event.get("season", {}).get("slug", "")
52
+ or "group-stage"
53
+ ).replace("-", " ").title()
54
+
55
+ fixtures.append({
56
+ "home": home_team["team"].get("abbreviation", "").upper(),
57
+ "away": away_team["team"].get("abbreviation", "").upper(),
58
+ "home_name": home_team["team"].get("displayName", ""),
59
+ "away_name": away_team["team"].get("displayName", ""),
60
+ "kickoff_utc": kickoff_utc,
61
+ "stage": stage,
62
+ })
63
+
64
+ print(f"Found {len(fixtures)} fixtures")
65
+ return fixtures
66
+
67
+
68
+ def main():
69
+ dry_run = "--dry-run" in sys.argv
70
+
71
+ try:
72
+ fixtures = fetch_fixtures()
73
+ except Exception as e:
74
+ print(f"ERROR: ESPN API failed: {e}")
75
+ sys.exit(1)
76
+
77
+ if not fixtures:
78
+ print("No fixtures found. Check ESPN API or date range.")
79
+ sys.exit(1)
80
+
81
+ # Save schedule.json regardless of dry-run
82
+ schedule_path = WORK_DIR / "schedule.json"
83
+ schedule_path.write_text(json.dumps(fixtures, indent=2))
84
+ print(f"Saved {len(fixtures)} fixtures to schedule.json")
85
+
86
+ now = datetime.now(timezone.utc)
87
+ upcoming = [
88
+ f for f in fixtures
89
+ if f.get("kickoff_utc") and datetime.fromisoformat(
90
+ f["kickoff_utc"].replace("Z", "+00:00")
91
+ ) > now
92
+ ]
93
+ past = len(fixtures) - len(upcoming)
94
+ print(f" {len(upcoming)} upcoming, {past} already past")
95
+
96
+ added = 0
97
+ skipped = 0
98
+
99
+ for fixture in upcoming:
100
+ home = fixture["home"]
101
+ away = fixture["away"]
102
+ kickoff = fixture["kickoff_utc"]
103
+
104
+ if not home or not away or not kickoff:
105
+ continue
106
+
107
+ p_line, r_line = schedule_match.make_cron_lines(home, away, kickoff)
108
+ print(f"\n {home} vs {away} — {kickoff}")
109
+ print(f" {p_line.splitlines()[-1]}")
110
+ print(f" {r_line.splitlines()[-1]}")
111
+
112
+ if not dry_run:
113
+ try:
114
+ schedule_match.add_cron_entries(home, away, kickoff)
115
+ added += 1
116
+ except Exception as e:
117
+ print(f" WARNING: cron write failed: {e}")
118
+ skipped += 1
119
+ else:
120
+ added += 1
121
+
122
+ if dry_run:
123
+ print(f"\nDRY RUN: would schedule {added} matches. Run without --dry-run to apply.")
124
+ else:
125
+ print(f"\nScheduled {added} matches ({skipped} failed).")
126
+ # Push schedule.json to GitHub
127
+ try:
128
+ subprocess.run(
129
+ ["git", "add", "schedule.json"],
130
+ cwd=WORK_DIR, check=True, capture_output=True,
131
+ )
132
+ subprocess.run(
133
+ ["git", "commit", "-m", "setup: add WC 2026 fixture schedule"],
134
+ cwd=WORK_DIR, check=True, capture_output=True,
135
+ )
136
+ subprocess.run(["git", "push"], cwd=WORK_DIR, check=True, capture_output=True)
137
+ print("Pushed schedule.json to GitHub")
138
+ except Exception as e:
139
+ print(f"Git push failed (schedule.json saved locally): {e}")
140
+
141
+ print("\nDone. Verify with: crontab -l")
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
setup_wallet.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-time wallet setup. Run manually once before first match.
2
+
3
+ Generates a fresh Ethereum keypair and prints the values to add to .env.
4
+ For base-sepolia testing, prints faucet URLs to get test USDC.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+ from dotenv import load_dotenv
11
+ from eth_account import Account
12
+
13
+ load_dotenv()
14
+
15
+
16
+ def main():
17
+ network = os.getenv("NETWORK", "base-mainnet")
18
+ print(f"Generating new Ethereum wallet for {network}...\n")
19
+
20
+ account = Account.create()
21
+ address = account.address
22
+ private_key = account.key.hex()
23
+ if not private_key.startswith("0x"):
24
+ private_key = "0x" + private_key
25
+
26
+ print("=" * 60)
27
+ print("Add these to your .env file:")
28
+ print("=" * 60)
29
+ print(f"WALLET_ADDRESS={address}")
30
+ print(f"WALLET_PRIVATE_KEY={private_key}")
31
+ print("=" * 60)
32
+ print()
33
+
34
+ if network == "base-sepolia":
35
+ print("TESTNET SETUP — fund your wallet with test USDC:")
36
+ print(f" Address: {address}")
37
+ print()
38
+ print(" 1. Get test ETH (for gas) from:")
39
+ print(" https://www.coinbase.com/faucets/base-ethereum-goerli-faucet")
40
+ print()
41
+ print(" 2. Get test USDC from:")
42
+ print(" https://faucet.circle.com/ (select Base Sepolia)")
43
+ print()
44
+ print(" USDC contract on Base Sepolia:")
45
+ print(" 0x036CbD53842c5426634e7929541eC2318f3dCF7e")
46
+ else:
47
+ print("MAINNET SETUP — fund your wallet with real USDC on Base:")
48
+ print(f" Address: {address}")
49
+ print()
50
+ print(" Transfer USDC to this address on Base mainnet.")
51
+ print(" Minimum recommended balance: $2.00 USDC")
52
+ print()
53
+ print(" USDC contract on Base mainnet:")
54
+ print(" 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
55
+
56
+ print()
57
+ print("WARNING: Store your private key securely. Never commit .env to git.")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
strategy.md ADDED
File without changes
templates/index.html ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>⚽ World Cup Prediction Agent</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { background: #0d0d0d; color: #e0e0e0; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 14px; line-height: 1.6; }
10
+ a { color: #4dabf7; text-decoration: none; }
11
+ a:hover { text-decoration: underline; }
12
+
13
+ .container { max-width: 900px; margin: 0 auto; padding: 24px 16px; }
14
+
15
+ header { border-bottom: 1px solid #333; padding-bottom: 16px; margin-bottom: 24px; }
16
+ header h1 { font-size: 20px; color: #fff; }
17
+ header .meta { display: flex; gap: 24px; margin-top: 8px; color: #888; font-size: 12px; }
18
+ header .meta span { color: #e0e0e0; }
19
+
20
+ nav { margin-bottom: 24px; display: flex; gap: 16px; font-size: 13px; }
21
+
22
+ h2 { font-size: 14px; color: #aaa; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; border-bottom: 1px solid #222; padding-bottom: 6px; }
23
+
24
+ .section { margin-bottom: 32px; }
25
+
26
+ /* Upcoming */
27
+ .upcoming-grid { display: flex; flex-direction: column; gap: 6px; }
28
+ .upcoming-item { background: #161616; border: 1px solid #2a2a2a; border-radius: 4px; padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; }
29
+ .upcoming-item .teams { font-weight: bold; color: #fff; }
30
+ .upcoming-item .kick { color: #666; font-size: 12px; }
31
+ .upcoming-item .stage { color: #555; font-size: 11px; }
32
+
33
+ /* Match cards */
34
+ .match-card { background: #111; border: 1px solid #2a2a2a; border-radius: 6px; margin-bottom: 16px; overflow: hidden; }
35
+ .match-header { padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; background: #161616; border-bottom: 1px solid #222; }
36
+ .match-title { font-size: 15px; font-weight: bold; color: #fff; }
37
+ .match-time { font-size: 11px; color: #555; }
38
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: bold; }
39
+ .badge.correct { background: #1a3a1a; color: #4caf50; border: 1px solid #4caf50; }
40
+ .badge.incorrect { background: #3a1a1a; color: #f44336; border: 1px solid #f44336; }
41
+ .badge.pending { background: #1a1a3a; color: #888; border: 1px solid #444; }
42
+
43
+ .match-body { padding: 12px 16px; }
44
+
45
+ .prediction-row { display: flex; gap: 24px; margin-bottom: 12px; flex-wrap: wrap; }
46
+ .pred-item { }
47
+ .pred-label { font-size: 10px; color: #555; text-transform: uppercase; }
48
+ .pred-value { font-size: 14px; color: #e0e0e0; }
49
+ .pred-value.pick { font-size: 16px; font-weight: bold; color: #fff; }
50
+
51
+ .reasoning { font-size: 12px; color: #888; font-style: italic; margin-bottom: 12px; }
52
+
53
+ /* x402 table */
54
+ .x402-section { margin-top: 8px; }
55
+ .x402-label { font-size: 10px; color: #555; text-transform: uppercase; margin-bottom: 4px; }
56
+ .x402-table { width: 100%; border-collapse: collapse; font-size: 12px; }
57
+ .x402-table th { text-align: left; color: #555; font-weight: normal; padding: 3px 6px; border-bottom: 1px solid #222; }
58
+ .x402-table td { padding: 4px 6px; color: #aaa; border-bottom: 1px solid #1a1a1a; vertical-align: top; }
59
+ .x402-table td:first-child { color: #4dabf7; font-size: 11px; word-break: break-all; max-width: 300px; }
60
+ .x402-table td.cost { color: #f9a825; white-space: nowrap; }
61
+ .no-x402 { font-size: 12px; color: #444; font-style: italic; }
62
+
63
+ .result-bar { margin-top: 10px; padding: 8px 12px; border-radius: 4px; font-size: 13px; }
64
+ .result-bar.correct { background: #0d1f0d; border-left: 3px solid #4caf50; }
65
+ .result-bar.incorrect { background: #1f0d0d; border-left: 3px solid #f44336; }
66
+ .result-score { font-weight: bold; color: #fff; }
67
+
68
+ .evaluation { margin-top: 8px; font-size: 12px; color: #777; }
69
+ .detail-link { font-size: 11px; color: #555; margin-top: 6px; display: block; }
70
+
71
+ /* Strategy page */
72
+ .strategy-box { background: #111; border: 1px solid #2a2a2a; border-radius: 6px; padding: 16px; }
73
+ pre { white-space: pre-wrap; word-wrap: break-word; color: #bbb; font-size: 13px; }
74
+ .strategy-empty { color: #444; font-style: italic; }
75
+
76
+ .history-item { border-bottom: 1px solid #1a1a1a; padding: 10px 0; }
77
+ .history-match { font-size: 12px; color: #888; margin-bottom: 6px; }
78
+ .history-snapshot { font-size: 11px; color: #555; }
79
+
80
+ /* Detail page */
81
+ .detail-section { margin-bottom: 24px; }
82
+ .detail-section h3 { font-size: 12px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
83
+ .full-text { font-size: 12px; color: #aaa; white-space: pre-wrap; background: #0a0a0a; border: 1px solid #1e1e1e; border-radius: 4px; padding: 12px; max-height: 400px; overflow-y: auto; }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <div class="container">
88
+
89
+ <header>
90
+ <h1>⚽ World Cup Prediction Agent</h1>
91
+ <div class="meta">
92
+ {% if stats %}
93
+ <div>Record: <span>{{ stats.wins }}W–{{ stats.losses }}L ({{ stats.pct }})</span></div>
94
+ <div>Matches predicted: <span>{{ stats.total }}</span></div>
95
+ {% endif %}
96
+ <div><a href="/">Dashboard</a> · <a href="/strategy">Strategy</a></div>
97
+ </div>
98
+ </header>
99
+
100
+ {% if detail %}
101
+ <!-- ─── Match Detail Page ─── -->
102
+ <div>
103
+ <a href="/" style="color:#555;font-size:12px;">← Back to dashboard</a>
104
+ </div>
105
+ <br>
106
+
107
+ {% set r = detail %}
108
+ {% set pred = r.prediction %}
109
+ {% set res = r.result %}
110
+ {% if res %}
111
+ {% if r.correct %}
112
+ <div class="result-bar correct">
113
+ <span class="result-score">{{ r.home }} {{ res.home_score }}–{{ res.away_score }} {{ r.away }}</span>
114
+ &nbsp;·&nbsp; <span class="badge correct">✓ CORRECT</span>
115
+ </div>
116
+ {% else %}
117
+ <div class="result-bar incorrect">
118
+ <span class="result-score">{{ r.home }} {{ res.home_score }}–{{ res.away_score }} {{ r.away }}</span>
119
+ &nbsp;·&nbsp; <span class="badge incorrect">✗ INCORRECT</span>
120
+ </div>
121
+ {% endif %}
122
+ {% else %}
123
+ <div style="font-size:13px;color:#555;">Result not yet available</div>
124
+ {% endif %}
125
+ <br>
126
+
127
+ <div class="detail-section">
128
+ <h3>Prediction</h3>
129
+ <div class="prediction-row">
130
+ <div class="pred-item"><div class="pred-label">Pick</div><div class="pred-value pick">{{ pred.pick | upper }}</div></div>
131
+ <div class="pred-item"><div class="pred-label">Confidence</div><div class="pred-value">{{ pred.confidence }}/10</div></div>
132
+ <div class="pred-item"><div class="pred-label">Bet Size</div><div class="pred-value">${{ pred.bet }}</div></div>
133
+ <div class="pred-item"><div class="pred-label">Kickoff</div><div class="pred-value" style="font-size:12px;">{{ r.kickoff }}</div></div>
134
+ </div>
135
+ <div class="reasoning">{{ pred.reasoning }}</div>
136
+ </div>
137
+
138
+ <div class="detail-section">
139
+ <h3>x402 Research ({{ r.services_used | length }} purchased, ${{ "%.4f" | format(r.research_cost) }} spent)</h3>
140
+ {% if r.services_planned %}
141
+ <table class="x402-table">
142
+ <tr><th>Endpoint</th><th>Cost</th><th>Status</th><th>Reason</th></tr>
143
+ {% set used_urls = r.services_used | map(attribute='source') | list %}
144
+ {% for svc in r.services_planned %}
145
+ <tr>
146
+ <td>{{ svc.url }}</td>
147
+ <td class="cost">${{ "%.4f" | format(svc.cost) }}</td>
148
+ <td>{% if svc.url in used_urls %}<span style="color:#4caf50">✓ bought</span>{% else %}<span style="color:#555">skipped</span>{% endif %}</td>
149
+ <td>{{ svc.reason }}</td>
150
+ </tr>
151
+ {% endfor %}
152
+ </table>
153
+ {% if r.services_used %}
154
+ <br>
155
+ <div style="font-size:11px;color:#555;">Data received:</div>
156
+ {% for svc in r.services_used %}
157
+ <div style="margin-top:8px;font-size:11px;color:#4dabf7;">{{ svc.source }}</div>
158
+ {% endfor %}
159
+ {% endif %}
160
+ {% else %}
161
+ <div class="no-x402">No services were considered for this match.</div>
162
+ {% endif %}
163
+ </div>
164
+
165
+ <div class="detail-section">
166
+ <h3>Full Gemini Reasoning</h3>
167
+ <div class="full-text">{{ r.full_reasoning }}</div>
168
+ </div>
169
+
170
+ {% if r.evaluation %}
171
+ <div class="detail-section">
172
+ <h3>Post-Match Evaluation</h3>
173
+ <div class="full-text">{{ r.evaluation }}</div>
174
+ </div>
175
+ {% endif %}
176
+
177
+ {% if r.strategy_snapshot %}
178
+ <div class="detail-section">
179
+ <h3>Strategy at Time of Prediction</h3>
180
+ <div class="full-text">{{ r.strategy_snapshot if r.strategy_snapshot else "(no strategy yet at time of prediction)" }}</div>
181
+ </div>
182
+ {% endif %}
183
+
184
+ {% elif strategy_page %}
185
+ <!-- ─── Strategy Page ─── -->
186
+ <div class="section">
187
+ <h2>Current Strategy</h2>
188
+ <div class="strategy-box">
189
+ {% if strategy %}
190
+ <pre>{{ strategy }}</pre>
191
+ {% else %}
192
+ <div class="strategy-empty">No strategy yet — will be written after the first match reflection.</div>
193
+ {% endif %}
194
+ </div>
195
+ </div>
196
+
197
+ {% if strategy_history %}
198
+ <div class="section">
199
+ <h2>Strategy Evolution</h2>
200
+ {% for item in strategy_history | reverse %}
201
+ <div class="history-item">
202
+ <div class="history-match">After {{ item.match }} ({{ item.kickoff[:10] if item.kickoff else '?' }})</div>
203
+ <div class="history-snapshot">{{ item.snapshot[:300] if item.snapshot else "(empty at this point)" }}{% if item.snapshot and item.snapshot | length > 300 %}…{% endif %}</div>
204
+ </div>
205
+ {% endfor %}
206
+ {% endif %}
207
+
208
+ {% else %}
209
+ <!-- ─── Dashboard ─── -->
210
+
211
+ {% if upcoming %}
212
+ <div class="section">
213
+ <h2>Upcoming Matches</h2>
214
+ <div class="upcoming-grid">
215
+ {% for f in upcoming %}
216
+ <div class="upcoming-item">
217
+ <div>
218
+ <span class="teams">{{ f.home }} vs {{ f.away }}</span>
219
+ <span class="stage" style="margin-left:8px;">{{ f.stage }}</span>
220
+ </div>
221
+ <div class="kick">{{ f.kickoff_utc[:16].replace("T", " ") }} UTC</div>
222
+ </div>
223
+ {% endfor %}
224
+ </div>
225
+ </div>
226
+ {% endif %}
227
+
228
+ {% if results %}
229
+ <div class="section">
230
+ <h2>Predictions</h2>
231
+ {% for r in results %}
232
+ {% set pred = r.prediction %}
233
+ {% set res = r.result %}
234
+ <div class="match-card">
235
+ <div class="match-header">
236
+ <div>
237
+ <div class="match-title">{{ r.match }}</div>
238
+ <div class="match-time">{{ r.kickoff[:16].replace("T", " ") }} UTC</div>
239
+ </div>
240
+ <div>
241
+ {% if r.correct is not none %}
242
+ {% if r.correct %}
243
+ <span class="badge correct">✓ CORRECT</span>
244
+ {% else %}
245
+ <span class="badge incorrect">✗ INCORRECT</span>
246
+ {% endif %}
247
+ {% else %}
248
+ <span class="badge pending">PENDING</span>
249
+ {% endif %}
250
+ </div>
251
+ </div>
252
+ <div class="match-body">
253
+ <div class="prediction-row">
254
+ <div class="pred-item"><div class="pred-label">Pick</div><div class="pred-value pick">{{ pred.pick | upper }}</div></div>
255
+ <div class="pred-item"><div class="pred-label">Confidence</div><div class="pred-value">{{ pred.confidence }}/10</div></div>
256
+ <div class="pred-item"><div class="pred-label">Bet</div><div class="pred-value">${{ pred.bet }}</div></div>
257
+ <div class="pred-item"><div class="pred-label">Research cost</div><div class="pred-value" style="color:#f9a825;">${{ "%.4f" | format(r.research_cost) }}</div></div>
258
+ </div>
259
+ <div class="reasoning">"{{ pred.reasoning }}"</div>
260
+
261
+ <!-- x402 purchase log -->
262
+ {% if r.services_used %}
263
+ <div class="x402-section">
264
+ <div class="x402-label">x402 data purchased</div>
265
+ <table class="x402-table">
266
+ <tr><th>Endpoint</th><th>Cost</th><th>Reason</th></tr>
267
+ {% for svc in r.services_used %}
268
+ <tr>
269
+ <td>{{ svc.source }}</td>
270
+ <td class="cost">${{ "%.4f" | format(svc.cost) }}</td>
271
+ <td>{{ svc.get('reason', '') }}</td>
272
+ </tr>
273
+ {% endfor %}
274
+ </table>
275
+ </div>
276
+ {% elif r.services_planned %}
277
+ <div class="no-x402">{{ r.services_planned | length }} endpoint(s) considered but none purchased (budget or availability).</div>
278
+ {% else %}
279
+ <div class="no-x402">No x402 data purchased for this match.</div>
280
+ {% endif %}
281
+
282
+ {% if res %}
283
+ <div class="result-bar {{ 'correct' if r.correct else 'incorrect' }}" style="margin-top:10px;">
284
+ Result: <span class="result-score">{{ r.home }} {{ res.home_score }}–{{ res.away_score }} {{ r.away }}</span>
285
+ {% if r.evaluation %}
286
+ <div class="evaluation">{{ r.evaluation[:200] }}{% if r.evaluation | length > 200 %}…{% endif %}</div>
287
+ {% endif %}
288
+ </div>
289
+ {% endif %}
290
+
291
+ <a href="/match/{{ r.home }}_{{ r.away }}" class="detail-link">View full reasoning & reflection →</a>
292
+ </div>
293
+ </div>
294
+ {% endfor %}
295
+ </div>
296
+
297
+ {% else %}
298
+ <div style="color:#444;font-style:italic;padding:32px 0;">
299
+ No predictions yet. The agent will make its first prediction 30 minutes before the next match.
300
+ </div>
301
+ {% endif %}
302
+
303
+ {% if strategy %}
304
+ <div class="section">
305
+ <h2>Current Strategy <a href="/strategy" style="font-size:11px;color:#555;font-weight:normal;margin-left:8px;">full history →</a></h2>
306
+ <div class="strategy-box">
307
+ <pre>{{ strategy[:600] }}{% if strategy | length > 600 %}
308
+
309
+ <a href="/strategy">… read more</a>{% endif %}</pre>
310
+ </div>
311
+ </div>
312
+ {% endif %}
313
+
314
+ {% endif %}
315
+
316
+ </div>
317
+ </body>
318
+ </html>
wallet.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wallet helpers — balance checking and x402 payment session creation."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ from dotenv import load_dotenv
7
+ from eth_account import Account
8
+ from web3 import Web3
9
+ from x402 import x402ClientSync
10
+ from x402.http.clients.requests import x402_requests
11
+ from x402.mechanisms.evm.exact import ExactEvmScheme
12
+
13
+ load_dotenv()
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ USDC_ABI = [
18
+ {
19
+ "inputs": [{"name": "account", "type": "address"}],
20
+ "name": "balanceOf",
21
+ "outputs": [{"name": "", "type": "uint256"}],
22
+ "stateMutability": "view",
23
+ "type": "function",
24
+ }
25
+ ]
26
+
27
+ USDC_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
28
+ USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
29
+
30
+
31
+ def _network_config() -> tuple[str, str, str]:
32
+ """Return (network_name, rpc_url, usdc_address, chain_pattern)."""
33
+ network = os.getenv("NETWORK", "base-mainnet")
34
+ if network == "base-mainnet":
35
+ return "base-mainnet", "https://mainnet.base.org", USDC_MAINNET, "eip155:8453"
36
+ return "base-sepolia", "https://sepolia.base.org", USDC_SEPOLIA, "eip155:84532"
37
+
38
+
39
+ def get_balance() -> float:
40
+ """Return current USDC balance of WALLET_ADDRESS as a float."""
41
+ try:
42
+ _, rpc_url, usdc_address, _ = _network_config()
43
+ address = os.getenv("WALLET_ADDRESS", "")
44
+ if not address:
45
+ log.warning("WALLET_ADDRESS not set in .env")
46
+ return 0.0
47
+ w3 = Web3(Web3.HTTPProvider(rpc_url))
48
+ contract = w3.eth.contract(
49
+ address=Web3.to_checksum_address(usdc_address), abi=USDC_ABI
50
+ )
51
+ raw = contract.functions.balanceOf(Web3.to_checksum_address(address)).call()
52
+ return raw / 1_000_000
53
+ except Exception as e:
54
+ log.warning("Balance check failed: %s", e)
55
+ return 0.0
56
+
57
+
58
+ def get_x402_client():
59
+ """Return a requests.Session that auto-handles 402 payment responses."""
60
+ _, _, _, chain_pattern = _network_config()
61
+ private_key = os.getenv("WALLET_PRIVATE_KEY", "")
62
+ if not private_key.startswith("0x"):
63
+ private_key = "0x" + private_key
64
+ account = Account.from_key(private_key)
65
+ scheme = ExactEvmScheme(signer=account)
66
+ client = x402ClientSync()
67
+ client.register(chain_pattern, scheme)
68
+ return x402_requests(client)