-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
786 lines (718 loc) · 32.9 KB
/
Copy pathmain.py
File metadata and controls
786 lines (718 loc) · 32.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
#!/usr/bin/env python3
"""
Azurro (Snappi 2.0) — Azuro-only betting bot.
Run: .venv/bin/python main.py
Hunter loop (Thorold 05:00–00:00): poll live football candidates (see azuro_feed_client /
MIGRATION-AZURO.md — migrating feed Graph → Toolkit REST), snap one game per cycle with Under
market, Sentry (Gemini) for colour, Telegram alert, AzuroSnaps sheet, place bet via relayer.
Live gate: WebSocket when AZURO_USE_WEBSOCKET_LIVE=1.
"""
import json
import logging
import os
import sys
import threading
import time
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
_root = os.path.dirname(os.path.abspath(__file__))
os.chdir(_root)
sys.path.insert(0, _root)
from dotenv import load_dotenv
load_dotenv(os.path.join(_root, ".env"))
# Local Azurro notifier (Telegram + Sentry)
import notifier
if os.getenv("AZURO_TELEGRAM_BOT_TOKEN"):
os.environ.setdefault("TELEGRAM_BOT_TOKEN", os.getenv("AZURO_TELEGRAM_BOT_TOKEN"))
if os.getenv("AZURO_TELEGRAM_CHAT_ID"):
os.environ.setdefault("TELEGRAM_CHAT_ID", os.getenv("AZURO_TELEGRAM_CHAT_ID"))
# Default odds for entries when not from subgraph
DEFAULT_ODDS = 1.85
# Paths
LOG_FILE = os.path.join(_root, "azurro.log")
SNAPPED_GAMES_JSON = os.path.join(_root, "snapped_azuro_games.json")
POLL_INTERVAL_SECONDS = 120
THOROLD_TZ = ZoneInfo("America/Toronto")
HUNTER_START_HOUR = 5
HUNTER_END_HOUR = 24 # 05:00–23:59 = Hunter
# Unit sizing
UNIT_DENOM = 4.0
UNIT_MAP = {"GREEN": 3.0, "YELLOW": 2.0, "RED": 0.5}
# Remote pause: when True, Hunter skips live monitoring and auto-placement
is_paused: bool = False
# Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(LOG_FILE, encoding="utf-8"),
],
)
logger = logging.getLogger(__name__)
def get_thorold_now() -> datetime:
return datetime.now(THOROLD_TZ)
def is_hunter_phase() -> bool:
now = get_thorold_now()
return HUNTER_START_HOUR <= now.hour < HUNTER_END_HOUR
def _usage_date() -> date:
"""Usage date for snapped-games reset (Thorold, switch at 05:00)."""
now = get_thorold_now()
if now.hour < HUNTER_START_HOUR:
return (now.date() - timedelta(days=1))
return now.date()
def _load_snapped_games() -> set[str]:
if not os.path.isfile(SNAPPED_GAMES_JSON):
return set()
try:
with open(SNAPPED_GAMES_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("date") == _usage_date().isoformat():
return set(data.get("game_ids") or [])
except (OSError, json.JSONDecodeError):
pass
return set()
def _save_snapped_games(game_ids: set[str]) -> None:
try:
with open(SNAPPED_GAMES_JSON, "w", encoding="utf-8") as f:
json.dump({"date": _usage_date().isoformat(), "game_ids": list(game_ids)}, f)
except OSError:
pass
def _get_next_snap_id() -> int:
counter_path = os.path.join(_root, "azuro_snap_counter.json")
usage = _usage_date().isoformat()
if os.path.isfile(counter_path):
try:
with open(counter_path, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("date") == usage:
next_id = int(data.get("next_id", 1))
else:
next_id = 1
except (OSError, json.JSONDecodeError, ValueError, TypeError):
next_id = 1
else:
next_id = 1
try:
with open(counter_path, "w", encoding="utf-8") as f:
json.dump({"date": usage, "next_id": next_id + 1}, f)
except OSError:
pass
return next_id
def _unit_dollars() -> float:
try:
import azuro_relayer
bal = azuro_relayer.get_balance_usd()
if bal and bal > 0:
return bal / UNIT_DENOM
except Exception:
pass
return 1.0
def _approx_cad_from_usd(usd: float) -> float:
"""
Approximate conversion for display only.
We use a free FX endpoint and fall back to a conservative default.
"""
try:
import requests
# simple public endpoint; no API key required
r = requests.get(
"https://api.exchangerate.host/latest",
params={"base": "USD", "symbols": "CAD"},
timeout=8,
)
if r.status_code == 200:
data = r.json()
rate = (data.get("rates") or {}).get("CAD")
if rate:
return usd * float(rate)
except Exception:
pass
# fallback: 1 USD ~= 1.35 CAD (display-only)
return usd * 1.35
def _run_hunter_cycle() -> None:
import azuro_integration
import azuro_executor
import azuro_live_feed
import notifier
import azuro_sheets
try:
from memory_write import write_episode_memory
except Exception:
write_episode_memory = None # type: ignore[assignment]
adapter = azuro_integration.default_adapter
if not adapter.is_configured():
return
events = adapter.fetch_live_football_games()
if not events:
return
# All stats from API-Football: fuzzy match Azuro events → fixtures, fetch stats
azuro_live_feed.update_feed_for_live_games(events)
snapped = _load_snapped_games()
lines_tried = [(None, "2.5"), (None, "1.5"), (1, "2.5")]
for ev in events:
game_id = str(ev.get("gameId") or ev.get("id") or "")
if game_id in snapped:
continue
home = ev.get("homeTeam") or "Home"
away = ev.get("awayTeam") or "Away"
league = (ev.get("league") or "").strip() or ""
kickoff = ev.get("startTimestamp")
try:
kickoff_ts = int(kickoff) if kickoff is not None else None
except (TypeError, ValueError):
kickoff_ts = None
# Enrich from API-Football stats (shots, corners, fouls, score, minute)
stats = azuro_live_feed.get_stats_for_game(game_id)
fixture_id = stats.get("fixture_id")
# Optional: predictions summary from API-Football
predictions_text = ""
if fixture_id:
try:
from api_football import fetch_predictions_for_fixture
preds = fetch_predictions_for_fixture(int(fixture_id))
if preds:
p = preds[0]
teams = p.get("teams") or {}
home_t = (teams.get("home") or {}).get("name") or ""
away_t = (teams.get("away") or {}).get("name") or ""
advice = (p.get("advice") or "").strip()
percent = p.get("percent") or {}
home_p = percent.get("home")
draw_p = percent.get("draw")
away_p = percent.get("away")
parts = []
if home_p or draw_p or away_p:
parts.append(f"{home_p or '-'} / {draw_p or '-'} / {away_p or '-'}")
if advice:
parts.append(advice)
predictions_text = f"{home_t} vs {away_t}: " + " | ".join(parts) if parts else advice
except Exception:
predictions_text = ""
minute = stats.get("minute")
if minute is not None and 25 <= minute <= 30:
window_name = "Azuro · 28-Minute Scan"
elif minute is not None and 70 <= minute <= 75:
window_name = "Azuro · 73-Minute Scan"
else:
window_name = "Azuro Live"
for market_half, line in lines_tried:
entry = {
"name": f"{home} vs {away}",
"home": home,
"away": away,
"league": league,
"azuro_game_id": game_id,
"score": stats.get("score", "? - ?"),
"target_line": f"Under {line}",
"market_half": market_half,
"kickoff_ts": kickoff_ts,
"odds": DEFAULT_ODDS,
"total_shots": stats.get("total_shots", ""),
"total_corners": stats.get("total_corners", ""),
"fouls": stats.get("fouls", ""),
"fixture_id": fixture_id,
"predictions_text": predictions_text,
}
outcome = adapter.get_under_outcome_for_event_and_entry(ev, entry)
if outcome:
entry["_azuro_outcome"] = outcome
entry["odds"] = outcome.odds
# One snap per game
labels, narrative = notifier.ask_gemini_sentry([entry], {})
colours = labels if labels else ["GREEN"]
unit_dollars = _unit_dollars()
snap_id = _get_next_snap_id()
colour = colours[0] if colours else "GREEN"
stake_dollars = unit_dollars * UNIT_MAP.get(colour, 1.0)
msg_id, chat_id = notifier.send_azurro_alert(
window_name, [entry], unit_dollars, snap_id=snap_id, colours=colours
)
placed, order_id = azuro_executor.execute_small_for_entries(
[entry], colours, unit_dollars, UNIT_MAP, dry_run=False
)
azuro_sheets.log_bet_to_sheet(
entry, window_name, league,
batch_timestamp=get_thorold_now().isoformat(),
snap_id=snap_id,
sentry_colour=colour,
units=UNIT_MAP.get(colour, ""),
stake_dollars=stake_dollars,
sentry_narrative=narrative or "",
azuro_order_id=order_id or "",
)
if write_episode_memory:
try:
write_episode_memory(
entry,
window_name=window_name,
colour=colour,
narrative=narrative or "",
placed=bool(placed),
result=None,
source="hunter",
importance=0.7,
)
except Exception as e:
logger.warning("Memory write failed: %s", e)
if placed:
logger.info("Azurro: placed bet for %s vs %s", home, away)
snapped.add(game_id)
_save_snapped_games(snapped)
return
return
def _html_escape(s: str) -> str:
if not s:
return ""
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
def _run_bot_loop() -> None:
"""Background: Telegram bot — /status, /schedule, /balance, /pause, /resume, /placebet, /livecheck, /bets."""
global is_paused
token = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("AZURO_TELEGRAM_BOT_TOKEN")
if not token:
return
try:
import telebot
from api_football import fetch_todays_fixtures_schedule, _fuzzy_score
bot = telebot.TeleBot(token)
@bot.message_handler(commands=["status"])
def cmd_status(msg):
now = get_thorold_now()
hunter = is_hunter_phase()
mode = "Paused" if is_paused else "Active"
try:
import azuro_relayer
bal = azuro_relayer.get_balance_usd()
bal_str = f"${bal:.2f}" if bal is not None else "—"
except Exception:
bal_str = "—"
text = (
f"🕒 <b>Thorold:</b> {now.strftime('%Y-%m-%d %H:%M %Z')}\n"
f"🔵 <b>Azurro</b> | Hunter: {hunter} | Mode: {mode}\n"
f"💰 Balance: {bal_str}"
)
bot.reply_to(msg, text, parse_mode="HTML")
@bot.message_handler(commands=["schedule"])
def cmd_schedule(msg):
try:
sched = fetch_todays_fixtures_schedule(THOROLD_TZ)
if not sched:
bot.reply_to(msg, "Could not fetch today's schedule (API-Football).", parse_mode="HTML")
return
total = sched.get("total", 0)
by_hour = sched.get("by_hour") or {}
hours = sorted(by_hour.keys())
lines = [f"📅 <b>Today's fixtures</b> (API-Football): {total} total"]
if hours:
for h in hours[:24]:
lines.append(f" {h:02d}:00 — {by_hour[h]} fixture(s)")
bot.reply_to(msg, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Schedule failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["balance"])
def cmd_balance(msg):
try:
import azuro_relayer
bal = azuro_relayer.get_balance_usd()
if bal is None:
bot.reply_to(
msg,
"💰 <b>Azurro balance</b>\nCould not read on-chain token balance. Set AZURO_WALLET_ADDRESS and chain RPC URL(s) in .env.",
parse_mode="HTML",
)
return
unit = bal / UNIT_DENOM if bal > 0 else 0.0
max_stake = unit * max(UNIT_MAP.values()) if unit else 0.0
token_sym = azuro_relayer.get_active_token_symbol() if hasattr(azuro_relayer, "get_active_token_symbol") else "TOKEN"
cad = _approx_cad_from_usd(bal)
text = (
f"💰 <b>Azurro balance</b>\n"
f"{token_sym} (on-chain, approx): ${bal:.2f}\n"
f"Approx CAD: C${cad:.2f}\n"
f"1 unit: ${unit:.2f}\n"
f"Max stake (GREEN 3u): ${max_stake:.2f}"
)
bot.reply_to(msg, text, parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Balance failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["pause"])
def cmd_pause(msg):
global is_paused
is_paused = True
bot.reply_to(msg, "⏸️ <b>Azurro Paused.</b> Live monitoring and auto-placement stopped.", parse_mode="HTML")
@bot.message_handler(commands=["resume"])
def cmd_resume(msg):
global is_paused
is_paused = False
bot.reply_to(msg, "▶️ <b>Azurro Resumed.</b> Live monitoring and auto-placement active.", parse_mode="HTML")
@bot.message_handler(commands=["livecheck"])
def cmd_livecheck(msg):
try:
import azuro_integration
import azuro_live_feed
import time
adapter = azuro_integration.default_adapter
raw_events = adapter.fetch_live_football_games() if adapter.is_configured() else []
now_ts = int(time.time())
# Filter out obviously stale or far-future games; keep those that started within last 3h
# or start soon.
events: list[dict] = []
for ev in raw_events:
try:
ts = int(ev.get("startTimestamp") or 0)
except (TypeError, ValueError):
ts = 0
if ts and (now_ts - 3 * 3600) <= ts <= (now_ts + 1800):
events.append(ev)
n = len(events)
text = f"🔵 <b>Azurro live</b>\nData-feed games (filtered by time): {n}"
if n:
sample = events[0]
text += f"\nSample: {sample.get('homeTeam')} vs {sample.get('awayTeam')}"
azuro_live_feed.update_feed_for_live_games(events)
stats = azuro_live_feed.get_stats_for_game(str(sample.get("gameId") or sample.get("id") or ""))
if stats:
text += f"\nStats (API-Football): ✓ shots={stats.get('total_shots')} corners={stats.get('total_corners')}"
else:
text += "\nStats (API-Football): no match for sample"
else:
text += "\nNo fresh live games detected right now (data-feed may be stale)."
bot.reply_to(msg, text, parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Livecheck failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["placebet"])
def cmd_placebet(msg):
raw = (msg.text or "").strip()
parts = raw.split(maxsplit=1)
name_part = (parts[1] if len(parts) > 1 else "").strip()
if not name_part:
bot.reply_to(msg, "Usage: <code>/placebet Liverpool vs Chelsea</code> (match name for a live game)", parse_mode="HTML")
return
try:
import azuro_integration
import azuro_executor
adapter = azuro_integration.default_adapter
if not adapter.is_configured():
bot.reply_to(msg, "Azuro not configured.", parse_mode="HTML")
return
events = adapter.fetch_live_football_games()
if not events:
bot.reply_to(msg, "No live games in data-feed.", parse_mode="HTML")
return
now_ts = int(time.time())
# Only place bets on games that are in a near-now window.
fresh_events = []
for ev in events:
try:
ts = int(ev.get("startTimestamp") or 0)
except (TypeError, ValueError):
ts = 0
if ts and (now_ts - 3 * 3600) <= ts <= (now_ts + 1800):
fresh_events.append(ev)
if not fresh_events:
bot.reply_to(msg, "No fresh live markets detected right now (data-feed may be stale). Try /livecheck again shortly.", parse_mode="HTML")
return
name_lower = name_part.lower()
best_ev = None
best_score = 0.0
for ev in fresh_events:
home = (ev.get("homeTeam") or "").strip()
away = (ev.get("awayTeam") or "").strip()
combined = f"{home} vs {away}"
s1 = _fuzzy_score(name_lower, combined.lower())
s2 = _fuzzy_score(name_lower, f"{away} vs {home}".lower())
score = max(s1, s2)
if score > best_score and score >= 50:
best_score = score
best_ev = ev
if not best_ev:
bot.reply_to(msg, f"No live match found for «{_html_escape(name_part)}». Try part of team names.", parse_mode="HTML")
return
home = best_ev.get("homeTeam") or "Home"
away = best_ev.get("awayTeam") or "Away"
entry = {"name": f"{home} vs {away}", "home": home, "away": away, "league": best_ev.get("league") or "", "target_line": "Under 2.5", "market_half": None, "score": "? - ?", "odds": DEFAULT_ODDS}
outcome = adapter.get_under_outcome_for_event_and_entry(best_ev, entry)
if not outcome:
bot.reply_to(msg, f"Under market not found for {_html_escape(entry['name'])}.", parse_mode="HTML")
return
entry["_azuro_outcome"] = outcome
entry["odds"] = outcome.odds
unit_dollars = _unit_dollars()
placed, order_id = azuro_executor.execute_small_for_entries([entry], ["GREEN"], unit_dollars, UNIT_MAP, dry_run=False)
if placed:
bot.reply_to(msg, f"✅ Placed UNDER bet on {_html_escape(entry['name'])} (order: {order_id or '—'})", parse_mode="HTML")
else:
bot.reply_to(msg, f"❌ Placement failed for {_html_escape(entry['name'])}. Check logs.", parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Placebet failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["bets"])
def cmd_bets(msg):
try:
from azuro_feed_client import fetch_recent_live_bets_graphql
wallet = (os.getenv("AZURO_WALLET_ADDRESS") or "").strip()
if not wallet:
bot.reply_to(msg, "AZURO_WALLET_ADDRESS not set.", parse_mode="HTML")
return
# AZURIO: bet history via Graph (KEEP) — see MIGRATION-AZURO.md; seam = azuro_feed_client
bets = fetch_recent_live_bets_graphql(wallet, limit=10)
if not bets:
bot.reply_to(msg, "No recent Azuro bets.", parse_mode="HTML")
return
lines = ["🔵 <b>Recent Azuro bets</b>"]
for b in bets[:8]:
ts = b.get("createdBlockTimestamp")
ts_str = datetime.fromtimestamp(int(ts), tz=THOROLD_TZ).strftime("%m/%d %H:%M") if ts else "?"
amt = float(b.get("amount") or 0)
if amt >= 1_000_000:
amt = amt / 1_000_000 # USDT 6 decimals
line = f"• {b.get('status')} | ${amt:.2f} @ {b.get('odds')} | {b.get('result') or '—'} | {ts_str}"
lines.append(line)
bot.reply_to(msg, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Bets failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["slip"], content_types=["text", "photo"])
def cmd_slip(msg):
"""
Two-odds-a-day helper: evaluate a brother slip via text or photo.
Usage examples:
/slip stake=50 odds=2.0 Liverpool vs Chelsea; Roma vs Milan
Or:
Send /slip with a photo of the slip (caption optional).
"""
stake = None
total_odds = None
legs = []
description = ""
has_photo = bool(getattr(msg, "photo", None))
caption = (msg.caption or "").strip() if hasattr(msg, "caption") else ""
if has_photo:
# Vision path: download the highest-res photo and let Gemini parse it.
try:
photo_sizes = msg.photo or []
best = photo_sizes[-1]
file_info = bot.get_file(best.file_id)
image_bytes = bot.download_file(file_info.file_path)
from slip_vision import parse_slip_from_image
slip = parse_slip_from_image(image_bytes, caption or None)
stake = slip.get("stake")
total_odds = slip.get("total_odds")
legs = slip.get("legs") or []
description = slip.get("description") or caption or ""
except Exception as e:
bot.reply_to(msg, f"Slip vision failed: {_html_escape(str(e))}", parse_mode="HTML")
return
else:
# Text-only fallback using the old parsing logic.
raw = (msg.text or "").strip()
parts = raw.split(maxsplit=1)
body = (parts[1] if len(parts) > 1 else "").strip()
if not body:
bot.reply_to(
msg,
"Usage: <code>/slip stake=50 odds=2.0 Liverpool vs Chelsea; Roma vs Milan</code>",
parse_mode="HTML",
)
return
rest = body
for token in body.split():
lower = token.lower()
if lower.startswith("stake="):
try:
stake = float(lower.split("=", 1)[1])
except ValueError:
stake = None
rest = rest.replace(token, "").strip()
elif lower.startswith("odds="):
try:
total_odds = float(lower.split("=", 1)[1])
except ValueError:
total_odds = None
rest = rest.replace(token, "").strip()
legs_text = [s.strip() for s in rest.split(";") if s.strip()]
legs = [{"fixture_hint": txt} for txt in legs_text]
description = rest
slip = {
"description": description,
"stake": stake,
"total_odds": total_odds,
"legs": legs,
}
try:
from notifier import ask_gemini_two_odds_sentry
colour, narrative = ask_gemini_two_odds_sentry(slip)
leg_summaries = []
for i, leg in enumerate(legs, start=1):
fh = leg.get("fixture_hint") or "?"
lh = leg.get("league_hint") or ""
leg_summaries.append(f"{i}. {fh}" + (f" ({lh})" if lh else ""))
lines = [
"🧾 <b>Two-odds slip check</b>",
f"Colour: <b>{_html_escape(colour)}</b>",
_html_escape(narrative),
]
if stake is not None or total_odds is not None:
meta_bits = []
if stake is not None:
meta_bits.append(f"stake={stake}")
if total_odds is not None:
meta_bits.append(f"odds={total_odds}")
lines.append("Details: " + _html_escape(", ".join(meta_bits)))
if leg_summaries:
lines.append("Legs:")
for ls in leg_summaries:
lines.append(_html_escape(ls))
bot.reply_to(msg, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Slip evaluation failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["teach"])
def cmd_teach(msg):
raw = (msg.text or "").strip()
parts = raw.split(maxsplit=1)
lesson = (parts[1] if len(parts) > 1 else "").strip()
if not lesson:
bot.reply_to(msg, "Usage: <code>/teach your observation here</code>", parse_mode="HTML")
return
try:
from memory_embeddings import embed_text
from memory_db import insert_memory_item
emb = embed_text(lesson)
insert_memory_item(
kind="note",
source="teacher",
text=lesson,
embedding=emb,
importance=0.9,
tags=["teacher"],
)
bot.reply_to(msg, "Saved lesson to Azurro's memory.", parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Teach failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["memory", "lessons"])
def cmd_memory(msg):
try:
from memory_db import get_conn
limit = 5
sql = """
SELECT created_at, kind, source, text
FROM memory_items
WHERE kind in ('lesson', 'note', 'weekly_theme')
ORDER BY created_at DESC
LIMIT %s
"""
rows = []
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, (limit,))
rows = cur.fetchall()
if not rows:
bot.reply_to(msg, "No lessons in memory yet.", parse_mode="HTML")
return
lines = ["🧠 <b>Recent Azurro lessons</b>"]
for created_at, kind, source, text in rows:
ts = created_at.strftime("%Y-%m-%d %H:%M")
lines.append(f"- [{kind}/{source} @ {ts}] { _html_escape(text[:300]) }")
bot.reply_to(msg, "\n".join(lines), parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Memory fetch failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(commands=["why"])
def cmd_why(msg):
raw = (msg.text or "").strip()
parts = raw.split(maxsplit=1)
query = (parts[1] if len(parts) > 1 else "").strip()
try:
from memory_db import get_conn
sql = """
SELECT created_at, features_json, text
FROM memory_items
WHERE kind in ('bet', 'skip')
ORDER BY created_at DESC
LIMIT 1
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(sql)
row = cur.fetchone()
if not row:
bot.reply_to(msg, "No recent decision found to explain.", parse_mode="HTML")
return
created_at, features_json, text = row
f = features_json or {}
decision = f.get("decision") or f.get("colour") or "UNKNOWN"
match_desc = f.get("match") or query or "recent Under snap"
explanation = (
f"Last recorded decision for <b>{_html_escape(match_desc)}</b> "
f"({created_at.strftime('%Y-%m-%d %H:%M')}): colour={_html_escape(str(decision))}. "
"See AzuroSnaps + Sentry notes for full context."
)
bot.reply_to(msg, explanation, parse_mode="HTML")
except Exception as e:
bot.reply_to(msg, f"Why failed: {_html_escape(str(e))}", parse_mode="HTML")
@bot.message_handler(func=lambda m: True, content_types=["text"])
def free_chat(msg):
text = (msg.text or "").strip()
if text.startswith("/"):
return # other commands handled above
try:
import os
from google import genai
api_key = os.getenv("GEMINI_API_KEY", "").strip()
if not api_key:
return
client = genai.Client(api_key=api_key)
prompt = (
"You are Azurro, an under-betting chairman and risk-focused investor's assistant. "
"Your job is to help the user think clearly about football bets, risk, and bankroll health, "
"not to hype or encourage reckless behaviour.\n\n"
"Personality:\n"
"- Calm, concise, level-headed; comfortable saying 'pass' or 'this is too risky'.\n"
"- Focused on maximizing long-run gains while protecting capital and minimizing drawdowns.\n"
"- Explains reasoning in 1–2 short sentences, in plain language.\n\n"
"Rules:\n"
"- Never change live parameters or execute bets yourself.\n"
"- Do not give imperative commands like 'slam this' or 'all-in'.\n"
"- Treat RED / avoid as the default when information is thin.\n\n"
f"User: {text}\n"
)
resp = client.models.generate_content(
model="models/gemini-2.0-flash",
contents=prompt,
)
reply = (resp.text or "").strip() if resp and resp.text else "..."
bot.reply_to(msg, reply)
except Exception:
# Stay silent on failure to avoid noisy chats during downtime.
return
while True:
try:
bot.infinity_polling(timeout=25)
except Exception as e:
logger.warning("Azurro bot polling: %s", e)
time.sleep(10)
except ImportError:
pass
def run() -> None:
try:
import azuro_relayer # noqa: F401
import azuro_executor
import azuro_integration
import azuro_live_feed # noqa: F401
except ImportError as e:
print("Azurro: missing module:", e)
sys.exit(1)
logger.info("Azurro starting — Hunter 05:00–00:00 Thorold")
notifier.send_simple_message("🔵 Azurro is online. Hunter phase when 05:00–00:00 Thorold.")
t = threading.Thread(target=_run_bot_loop, daemon=True)
t.start()
while True:
try:
if is_hunter_phase() and not is_paused:
_run_hunter_cycle()
time.sleep(POLL_INTERVAL_SECONDS)
except KeyboardInterrupt:
logger.info("Azurro stopped by user")
break
except Exception as e:
logger.exception("Hunter cycle error: %s", e)
time.sleep(POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
run()