"""
Daily Away CLV Signal Scanner
Pulls upcoming fixtures + Pinnacle/B365 odds from API-Football.
Runs Tue 9 AM and Fri 12:30 PM via Task Scheduler.
Checks for Away CLV >5% vs Pinnacle fair, emails signals with Kelly sizing,
tracks bankroll.

Strategy: Pure Away CLV — bet Away when B365A > Pinnacle fair by >5%.
"""
import pandas as pd
import urllib.request
import requests
import smtplib
import math
import json
import sys
import os
import time
from pathlib import Path
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from datetime import datetime, timezone, timedelta
import maps

# STDOUT MUST BE UTF-8, OR A TEAM NAME KILLS THE WHOLE RUN.
#
# 2026-08-31: the scan never happened. run_daily_signals.bat redirects to a log
# file, so Python picked the locale encoding (cp1252), and resolve_pending_bets
# printed a Polish club with an 'e-ogonek' in it. UnicodeEncodeError, uncaught,
# process dead -- at line ~784, inside the resolve step that runs BEFORE the
# signal scan. So one unprintable character in a SETTLED bet silently cancelled
# every xlow signal for the next day, and would have kept doing it daily for as
# long as that bet sat in pending.
#
# errors='replace' rather than plain utf-8: the point is that no team name can
# ever again take the process down, and a mangled character in a log line is
# not worth a missed day of signals.
for _s in (sys.stdout, sys.stderr):
    try:
        _s.reconfigure(encoding='utf-8', errors='replace')
    except Exception:
        pass

# ── Config ────────────────────────────────────────────────────────────────
def load_env(path):
    env = {}
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith('#') and '=' in line:
                    k, v = line.split('=', 1)
                    env[k.strip()] = v.strip()
    except FileNotFoundError:
        pass
    return env

_env = load_env(r'E:\soccer\.env')
EMAIL_ADDRESS      = _env.get('EMAIL_SENDER', '')
EMAIL_RECIPIENT    = _env.get('EMAIL_RECIPIENT', '')
EMAIL_APP_PASSWORD = _env.get('EMAIL_APP_PASSWORD', '')
KELLY_MULT          = 0.25
# ── b365 baseline strategy: RETIRED 2026-08-09 (user instruction) ──────────
# The old value rule staked the SAME signal on B365 *and* an exchange and booked
# both, so one bet produced two P&L rows: AZ Alkmaar v ADO Den Haag on 2026-08-08
# lost -3.73 on B365 AND -3.49 on Poly (-7.22 for one signal); PSV v Fortuna
# Sittard lost -1.78 on B365 AND -4.94 on Kalshi. This is the same defect fixed
# for xlow on 2026-08-01, but the one-venue rule there only arbitrates Kalshi vs
# Poly -- B365 is a third leg booked independently of it.
#
# Retiring rather than patching, because B365 is not a venue we can trade: it is
# a UK bookmaker used as a price REFERENCE, so "P&L" on it was never real. The
# b365 ODDS stay (clv_b365 is a live xlow diagnostic); only the betting stops.
B365_BETTING_ENABLED = False

EDGE_THRESHOLD_AWAY = 0.05
EDGE_THRESHOLD_HOME = 0.05
EDGE_UPPER_CAP_AWAY = 0.12  # AWAY CLV >=12% has been -ROI (long-shot trap, out-of-sample audit 2026-05-23)
EDGE_UPPER_CAP_HOME = 0.12  # HOME CLV >=12% has been -ROI (Kalshi-CLV audit 2026-05-23: 14 bets, -41% ROI)
MAX_BET_PCT         = 0.25   # Kelly cap

LIVE_DATA_DIR         = r'E:\soccer\live_data'
BANKROLL_FILE         = r'E:\soccer\data\bankroll.json'
KALSHI_BANKROLL_FILE  = r'E:\soccer\data\kalshi_bankroll.json'
POLY_BANKROLL_FILE    = r'E:\soccer\data\poly_bankroll.json'
PENDING_FILE          = r'E:\soccer\data\pending_bets.json'
HISTORY_FILE          = r'E:\soccer\data\bet_history.json'
SENT_LOG              = r'E:\soccer\data\sent_signals.json'
SIGNAL_LOG            = r'E:\soccer\data\signal_log.csv'

# ── Auto-trade (Phase 3) ─────────────────────────────────────────────────
# Hard caps in kalshi_trader.py enforce $5/order, 100 contracts/order, 99c price.
# These constants gate WHICH signals are eligible for automated placement.
# Defaults are OFF / minimal. Flip AUTO_TRADE_ENABLED to True only after the
# kalshi_kill.py status check has been verified and AUTO_TRADE_LEAGUES reviewed.
AUTO_TRADE_ENABLED      = True    # LIVE again 2026-08-31 (user) as the FALLBACK
# venue only: live_monitor tries Poly-US first and reaches Kalshi only when
# Poly-US has no market for the fixture -- which is most lower-division football
# (Bradford v Cambridge, E2, was the case that prompted it).
#
# The interference that stopped this on 2026-08-24 is now MITIGATED rather than
# merely accepted: mm_maker_live refuses to quote a leg we hold LONG, so the
# maker can no longer sell xlow's own contracts back and book the netted
# position as its own fills. The note below describes the failure that fix
# addresses; read it before turning either side up.
# STOPPED AFTER ONE DAY LIVE, and not because of the edge -- because xlow and the
# market maker share one Kalshi account and interfere. The maker quotes the same
# tickers xlow buys, and Kalshi nets both into ONE position per ticker, so:
#   * the maker sold 3 of xlow's own 5 FIO contracts back at the same 18c,
#     leaving a "5-contract bet" that is really 2 while the ledger still says 5
#   * the netted position was read as maker fills, booking phantom sales at 0c
#     that latched ratchet floors and killed two books outright
# The accounting bugs are fixed (order_id attribution + self-heal in
# mm_maker_live); the ECONOMIC interference is not, and needs the two strategies
# separated properly -- different subaccount, or the maker skipping tickers xlow
# holds. See memory/feedback_xlow_maker_position_netting.md.
#
# The kill switch (data/AUTO_TRADE_KILL) is ALSO engaged, because this flag only
# takes effect on a live_monitor restart and that needs an elevated shell. The
# kill file is checked per order, so it is what is actually stopping trades now.
# To resume: clear both, and restart live_monitor.
#
# Signals, pending_bets and the frozen forward test keep running unchanged.
# RE-ENABLED WITH THE SPREAD VERDICT STILL UNRESOLVED -- read this before
# assuming the objection below was answered. It was not. The prescribed gate
# (re-run entry-vs-close, check the half-spread has fallen toward 0.3c) was NOT
# run; the user chose to restart live on a small stake instead. What the current
# scope actually shows, net of a 5.18% average taker fee:
#     scoped primary        n=283  gross +26.02%  NET +20.83%  t+1.70
#     with pin_fair>=0.15   n=263  gross +20.27%  NET +15.17%  t+1.30
#     excluding 2026-08-22  n=249  gross +12.41%  NET  +7.25%  t+0.60
# Not significant, and one Saturday supplies ~76% of it -- the "hot streak"
# shape the old note warned against. Stakes are pinned at $1
# (live_monitor.XLOW_STAKE_FIXED) precisely because this is an unresolved bet on
# a thin edge, not a conviction sizing.
# Kill switch: main/kalshi_kill.py. To revert: set this False and RESTART
# live_monitor (see feedback_restart_live_monitor_after_config).
# WHY: xlow's edge was measured directly against its execution cost and loses.
# Line moves +0.31c toward our side (95% CI [-0.05, +0.67]) against a 0.67c
# half-spread -> net -0.36c/bet; even the optimistic end of that CI is only
# break-even, before Kalshi fees. Restricting to tight-spread entries nets
# -0.02c, so there is no sub-slice that clears the toll. See
# memory/project_xlow_spread_verdict.md (and project_clv_spread_finding, which
# found the same thing on the baseline signal in May, n=395).
# The SELECTION is not broken -- the market really does move our way. The signal
# is just worth about what it costs to take. So everything keeps logging:
# signals fire, pending_bets fills, the frozen forward test keeps accruing.
# RE-ENABLE ONLY IF SPREADS TIGHTEN. Re-run the entry-vs-close measurement first;
# if the average half-spread has fallen toward 0.3c the same rule becomes
# profitable untouched. Do NOT re-enable because of a hot streak.
# NOTE: poly_us_trader.AUTO_TRADE_ENABLED is a SEPARATE flag -- both must flip.
# The live xlow scope, 2026-08-23 (user): the leagues the backtest was measured
# on, minus BSA/CL1, plus NWSL -- and WITHOUT PL1/USL, which are deliberately
# left on paper because they confirm against Poly-US rather than Poly.com and
# that substitution is unvalidated (their slice runs +0.81% gross on n=71,
# thinner than the 5.18% average taker fee). They keep firing into the forward
# test; they just do not get real money yet.
# Mirrors research/forward_test_unique_book.PRIMARY_LEAGUES minus GAP_LEAGUES.
# KO1 added 2026-09-01 (user), matching the same edit to PRIMARY_LEAGUES. It
# confirms against Poly.com like the rest of this list, so unlike PL1/USL there
# is no unvalidated substitution -- it gets real money. See the post-hoc
# selection caveat in forward_test_unique_book next to SCOPE_FROZEN_KO1.
# XLOW TRADES IN ITS OWN SUBACCOUNT (2026-09-01, user: "going forward put xlow
# in the subaccount"). Kalshi nets long and short on the same ticker within a
# subaccount, so xlow buying a leg the maker is short partially closed each
# against the other -- the interference that stopped auto-trading on 08-24 and
# that the maker's never-quote-an-xlow-leg skip tried and failed to solve
# (removing that skip is what surfaced this).
#
# Subaccounts do not net, so keeping the maker on 0 and xlow on 1 removes the
# conflict at the root: both can hold opposite sides of the same market and
# neither closes the other.
#
# TWO THINGS TO KNOW.
#  * The subaccount must hold its OWN cash. Orders here draw on subaccount 1's
#    balance, not the primary's, and a $0 balance rejects every order.
#  * Positions and orders read subaccount 0 by DEFAULT, so anything that
#    reconciles xlow fills has to pass subaccount=1 explicitly or it will see
#    nothing and conclude the bet never happened.
#
# BACK TO 0 on 2026-09-29 (user killed the market maker; STOP_MAKER is in
# place). With no maker there is nothing to net against, and subaccount 1 held
# $0.00 after the 09-14 sweep -- every Kalshi order would have been rejected.
# resolve_bets.py reads BOTH subaccounts by order id, so settlement is
# unaffected. IF THE MAKER IS EVER RESTARTED, PUT THIS BACK TO 1 AND FUND IT
# FIRST -- the netting problem above comes straight back.
XLOW_SUBACCOUNT = 0

# CH1 added 2026-09-05 (user: "add CH1 to the xlow"). It was already FIRING
# signals -- fire_xlow_polyus covers it as a GAP league -- but this whitelist
# gates real money on BOTH venues, so every CH1 signal was paper only.
#
# IT IS NOT ADDED TO PRIMARY_LEAGUES, deliberately. Poly.com lists no
# match-winner market for Switzerland (checked: 3 sui- events, all corners
# props), so CH1 can only ever confirm against Poly-US -- a different book to
# the one the n=286 backtest validated. Folding that into the frozen headline
# is exactly what PL1 and USL were REMOVED for on 2026-08-27. CH1 keeps
# reporting in the separate xlow_polyus row where the confirming book is
# stated, and the primary number stays comparable to its own history.
#
# EVIDENCE IS THIN. n=11: WR 54.5% against 27.0% implied, ROI +87%, z+2.12.
# Six wins where three were expected. That is 11 bets and it was noticed by
# reading the by-league table, so it is discovery, not validation -- the same
# caveat KO1 carries. At $1 a bet the downside is bounded; treat the +87% as
# unmeasured rather than earned.
AUTO_TRADE_LEAGUES      = ['AR1', 'CH1', 'D1', 'D2', 'E0', 'E1', 'E2', 'F1',
                           'I1', 'I2', 'KO1', 'MLS', 'N1', 'NO1', 'NWSL', 'P1',
                           'SC0', 'SE1', 'SP1', 'SP2', 'T1']
AUTO_TRADE_EXCLUDE_LEAGUES = ['CAN', 'RO1',
                              # South America -- user instruction 2026-07-25
                              # ("stop trading SA leagues until we have more data.
                              # Keep it in the forward test though").
                              # AR1 REMOVED from this list 2026-08-23 (user:
                              # "keep AR1, but remove the other SA leagues") --
                              # it is the largest league in the live scope.
                              # The rest stay blocked for REAL MONEY only and
                              # keep tracking, which is what the note below means.
                              'BSA', 'BR2', 'CL1', 'CO1', 'PE1',
                              # SP2 and P1 moved live -> track 2026-09-07 (user).
                              # They stay in AUTO_TRADE_LEAGUES deliberately: this
                              # list is the real-money gate only, so signals keep
                              # firing into pending_bets and the frozen forward
                              # test and the scoped sample keeps growing. Removing
                              # them from AUTO_TRADE_LEAGUES instead would cut them
                              # at the earlier check and stop the tracking too.
                              'SP2', 'P1']
# NOTE this is real-money only -- excluded leagues keep firing signals into pending_bets
# and the frozen forward test, so the data DOES keep accruing and the test stays
# uncontaminated. That is exactly what the user asked for ("keep it in the forward test").
#
# WHY SOUTH AMERICA (2026-07-25), and what it is NOT: research/region_calibration_test.py
# found Pinnacle genuinely mis-prices SA -- homes implied 48.2% vs actual 60.3% (z+2.06),
# draws 27.4% vs 16.4% (z-2.10), consistent in 5/5 SA leagues, and it survives dropping
# BSA. That is the only region that deviates. BUT the error lands on HOME/DRAW, while 25
# of our 28 SA bets are AWAY -- and SA aways are calibrated (z-0.21). So this exclusion is
# NOT justified by that finding. The away-side hint (aways under 25c: 5 wins vs 7.7
# expected, z=-1.07) is well inside noise, and the SA bet record itself is z=-0.47.
# Treat this as a risk-comfort pause pending more data, same category as the
# AUTO_TRADE_MIN_PIN_FAIR floor -- NOT as evidence-backed league culling, which
# project_league_filter_study already showed is fitting noise. Revisit when SA passes
# ~130 fixtures (re-run region_calibration_test.py); drop for real only if SA aways
# reach |z| > 2.
# Standing caveat: BSA's 0-for-12 has a ~7% base rate at its implied win rates, and
# research/pin_calibration_by_league.py found NO structural mis-calibration there
# (home z=+0.99, draw z=-1.42 on n=32; ~130 fixtures needed to call it real). So this
# is a risk-comfort decision, not an evidence-backed one -- cf. project_league_filter_study,
# which found culling cold leagues on ROI is fitting noise. Revisit when BSA has ~130
# fixtures or if the calibration test ever flags it.
# 2026-07-23 (user instruction: "don't exclude any xlow eligible leagues"): dropped
# JP1/KO1/CL1/CN1/MX1/HR1/PE1/DK1/CZ1/BR2/CO1/NWSL/USL -- all have Kalshi+Poly.com
# (or Kalshi+Poly-US via GAP_LEAGUES) and can genuinely fire xlow, so excluding them
# was just an unexamined default, not a decision backed by data for most of them.
# CAN (no Poly.com, not a gap league) and RO1 (no Kalshi at all) stay listed, but
# it's moot -- xlow can never fire for either regardless of this list.
# All real orders are still capped at $1 (kalshi_trader.MAX_DOLLARS_PER_ORDER /
# poly_us_trader.MAX_DOLLARS_PER_ORDER) -- this list controls WHICH leagues can
# trade real money at all, not HOW MUCH.
                                       # (signals still logged to pending/history; only execution skipped)
AUTO_TRADE_DAILY_CAP    = None         # None = no daily cap; otherwise float dollars (e.g. 5.00)
AUTO_TRADE_PER_BET_CAP  = 1.00         # user cap, 2026-07-23 -- raise only when they say so (cannot exceed kalshi_trader cap)
AUTO_TRADE_MIN_PIN_FAIR = 0.15         # user instruction 2026-07-25: no real money on
                                       # longshots. EXECUTION-ONLY -- the signal still
                                       # fires, still lands in pending_bets, and still
                                       # counts in the frozen forward test, so the
                                       # strategy keeps being measured unfiltered. The
                                       # user named this look-ahead-derived (longshot
                                       # buckets looked worst in the backtest bucket
                                       # test) and deliberately confined it to sizing
                                       # rather than to the rule, precisely so it can't
                                       # contaminate the evaluation. Do NOT apply this
                                       # threshold anywhere in research/ or the forward
                                       # test -- if it leaks there, the test is fitting
                                       # the same data that chose the cutoff.
BASELINE_AUTO_TRADE_ENABLED = False    # 2026-07-24: only frozen xlow rules trade real
                                       # money. The baseline B365-CLV signal stays
                                       # paper-only (its own forward test is negative;
                                       # see project_edge_significance_status).
# Kalshi paper Kelly sizing COMPOUNDS off the live kalshi_bankroll.json tracker
# (committed-capital adjusted via `kalshi_available`), mirroring the B365 leg.
# Changed 2026-05-31 at user request. The email's "Kalshi @$1k" column still
# shows a fixed ¼-Kelly-of-$1000 reference for comparison (literal at L885).
AUTO_TRADE_LOG          = r'E:\soccer\data\auto_trade_log.json'
AUTO_TRADE_ERR_LOG      = r'E:\soccer\logs\auto_trade_errors.log'
AUTO_TRADE_KILL_FILE    = r'E:\soccer\data\AUTO_TRADE_KILL'

# API-Football
AF_KEY     = '81b487be73030f5dec980d2a1ef2c406'
AF_BASE    = 'https://v3.football.api-sports.io'
AF_HEADERS = {'x-apisports-key': AF_KEY}
AF_SEASON  = 2025   # 2025/26 season
AF_B365_ID = 8      # Bet365 bookmaker ID
AF_PIN_ID  = 4      # Pinnacle bookmaker ID

# League codes, IDs and names loaded from league_map.json via maps module.
LEAGUES      = maps.all_divs()
LEAGUE_NAMES = {d: maps.league_name(d) for d in LEAGUES}

# ── API-Football → football-data.co.uk team name map ─────────────────────
# Mappings are now in team_map.json (fdco field). Edit that file to add entries.
# This dict is kept as a local override for entries not yet in team_map.json.
FDCO_MAP = {
    # EPL (E0)
    'Manchester United':            'Man United',
    'Manchester City':              'Man City',
    'Nottingham Forest':            "Nott'm Forest",
    'Newcastle United':             'Newcastle',
    'Wolverhampton Wanderers':      'Wolves',
    'Wolverhampton':                'Wolves',
    'Leeds United':                 'Leeds',
    'West Ham United':              'West Ham',
    # Championship (E1)
    'Sheffield Wednesday':          'Sheffield Weds',
    'Oxford United':                'Oxford',
    'West Bromwich Albion':         'West Brom',
    'West Bromwich':                'West Brom',
    # League One (E2)
    'Peterborough United':          'Peterboro',
    'Peterborough':                 'Peterboro',
    'Wimbledon':                    'AFC Wimbledon',
    # Bundesliga (D1)
    'Eintracht Frankfurt':          'Ein Frankfurt',
    'FC Köln':                      'FC Koln',
    'Borussia Dortmund':            'Dortmund',
    'Borussia Mönchengladbach':     "M'gladbach",
    'Mönchengladbach':              "M'gladbach",
    "FC St. Pauli":                 'St Pauli',
    'St. Pauli':                    'St Pauli',
    # 2.Bundesliga (D2)
    'Fortuna Düsseldorf':           'Fortuna Dusseldorf',
    'Fortuna Dusseldorf':           'Fortuna Dusseldorf',
    'Schalke 04':                   'Schalke 04',
    '1. FC Nürnberg':               'Nurnberg',
    'Nürnberg':                     'Nurnberg',
    '1. FC Kaiserslautern':         'Kaiserslautern',
    'Holstein Kiel':                'Holstein Kiel',
    'Karlsruher SC':                'Karlsruhe',
    'SV Elversberg':                'Elversberg',
    # La Liga (SP1)
    'Athletic Club':                'Ath Bilbao',
    'Athletic Bilbao':              'Ath Bilbao',
    'Atlético Madrid':              'Ath Madrid',
    'Atletico Madrid':              'Ath Madrid',
    'Real Betis':                   'Betis',
    'Celta Vigo':                   'Celta',
    'Rayo Vallecano':               'Vallecano',
    'Real Sociedad':                'Sociedad',
    'Espanyol':                     'Espanol',
    # Segunda (SP2)
    'Deportivo de La Coruña':       'La Coruna',
    'Deportivo de la Coruna':       'La Coruna',
    'Deportivo La Coruna':          'La Coruna',
    'Real Sociedad B':              'Sociedad B',
    'Sporting Gijón':               'Sp Gijon',
    'Sporting Gijon':               'Sp Gijon',
    # Serie A (I1)
    'AC Milan':                     'Milan',
    'Hellas Verona':                'Verona',
    # Serie B (I2)
    'Südtirol':                     'Sudtirol',
    'SS Juve Stabia':               'Juve Stabia',
    # Ligue 1 (F1)
    'Paris Saint-Germain':          'Paris SG',
    'PSG':                          'Paris SG',
    'Stade Brest':                  'Brest',
    'Stade Rennais FC':             'Rennes',
    'Stade Rennais':                'Rennes',
    'RC Strasbourg':                'Strasbourg',
    'Strasbourg Alsace':            'Strasbourg',
    # Ligue 2 (F2)
    'Saint-Étienne':                'St Etienne',
    'AS Saint-Étienne':             'St Etienne',
    # Eredivisie (N1)
    'Fortuna Sittard':              'For Sittard',
    'PSV Eindhoven':                'PSV Eindhoven',
    'PSV':                          'PSV Eindhoven',
    'AZ Alkmaar':                   'AZ Alkmaar',
    'AZ':                           'AZ Alkmaar',
    'Go Ahead Eagles':              'Go Ahead Eagles',
    'NAC Breda':                    'NAC Breda',
    'FC Utrecht':                   'Utrecht',
    'FC Groningen':                 'Groningen',
    'NEC Nijmegen':                 'Nijmegen',
    'FC Twente':                    'Twente',
    'Sparta Rotterdam':             'Sparta Rotterdam',
    'Heracles Almelo':              'Heracles',
    # Liga Portugal (P1)
    'Sporting CP':                  'Sp Lisbon',
    'SC Braga':                     'Sp Braga',
    'Braga':                        'Sp Braga',
    'Gil Vicente':                  'Gil Vicente',
    'Vicente Barcelos':             'Gil Vicente',  # API-Football name for Gil Vicente
    'Estrela Amadora':              'Estrela',
    'Santa Clara':                  'Santa Clara',
    'Santa Clara Azores':           'Santa Clara',
    'FC Porto':                     'Porto',
    'SL Benfica':                   'Benfica',
    # Super Lig (T1)
    'Göztepe':                      'Goztep',
    'Goztepe':                      'Goztep',
    'Goztepe Izmir':                'Goztep',
    'İstanbul Başakşehir':          'Buyuksehyr',
    'Istanbul Basaksehir':          'Buyuksehyr',
    'Basaksehir':                   'Buyuksehyr',
    'Kocaeli':                      'Kocaelispor',
    # Greek SL (G1)
    'Olympiacos':                   'Olympiakos',
    'Aris Thessaloniki':            'Aris',
    'AEK Athens':                   'AEK',
    'Panaitolikos':                 'Panetolikos',
    'Levadiakos':                   'Levadeiakos',
    'OFI Crete':                    'OFI Crete',
    # Scottish (SC0)
    'Heart of Midlothian':          'Hearts',
    'St. Mirren':                   'St Mirren',
    'Dundee FC':                    'Dundee',
}

def fdco_name(name):
    """Translate API-Football team name to football-data.co.uk equivalent.
    Checks maps.py (team_map.json) first, then the local FDCO_MAP override, then falls back to as-is."""
    mapped = maps.fdco_name(name)
    if mapped != name:
        return mapped
    return FDCO_MAP.get(name, name)

# ── Bankroll persistence ─────────────────────────────────────────────────
def load_bankroll():
    try:
        with open(BANKROLL_FILE) as f:
            return float(json.load(f)['bankroll'])
    except:
        return 1000.0

def save_bankroll(amount):
    with open(BANKROLL_FILE, 'w') as f:
        json.dump({'bankroll': round(amount, 2), 'updated': datetime.now().strftime('%Y-%m-%d %H:%M')}, f, indent=2)

def load_kalshi_bankroll():
    try:
        with open(KALSHI_BANKROLL_FILE) as f:
            return float(json.load(f)['bankroll'])
    except:
        return 1000.0

def save_kalshi_bankroll(amount):
    with open(KALSHI_BANKROLL_FILE, 'w') as f:
        json.dump({'bankroll': round(amount, 2), 'updated': datetime.now().strftime('%Y-%m-%d %H:%M')}, f, indent=2)

def load_poly_bankroll():
    try:
        with open(POLY_BANKROLL_FILE) as f:
            return float(json.load(f)['bankroll'])
    except:
        return 1000.0

def save_poly_bankroll(amount):
    with open(POLY_BANKROLL_FILE, 'w') as f:
        json.dump({'bankroll': round(amount, 2), 'updated': datetime.now().strftime('%Y-%m-%d %H:%M')}, f, indent=2)

# ── Pending bets ─────────────────────────────────────────────────────────
def load_pending():
    try:
        with open(PENDING_FILE) as f:
            return json.load(f)
    except:
        return []

def save_pending(bets):
    with open(PENDING_FILE, 'w') as f:
        json.dump(bets, f, indent=2)

def load_history():
    try:
        with open(HISTORY_FILE) as f:
            return json.load(f)
    except:
        return []

def save_history(history):
    with open(HISTORY_FILE, 'w') as f:
        json.dump(history, f, indent=2)

def current_season():
    now = datetime.now(timezone.utc)
    y, m = now.year, now.month
    start_year = y if m >= 7 else y - 1
    return f"{start_year}{str(start_year + 1)[2:]}"

_season_cache = {}
def fetch_season_df(div, season):
    # MLS uses a single all-seasons file with different column names
    if div == 'MLS':
        key = ('MLS', 'all')
        if key in _season_cache:
            return _season_cache[key]
        url = 'https://www.football-data.co.uk/new/USA.csv'
        tmp = r'E:\soccer\data\_tmp_MLS.csv'
        try:
            urllib.request.urlretrieve(url, tmp)
            df = pd.read_csv(tmp, encoding='utf-8-sig')
            df['Date'] = pd.to_datetime(df['Date'], dayfirst=True, errors='coerce')
            # Normalize to standard column names used by resolve_pending_bets
            df = df.rename(columns={
                'Home': 'HomeTeam', 'Away': 'AwayTeam',
                'HG': 'FTHG', 'AG': 'FTAG', 'Res': 'FTR'
            })
            _season_cache[key] = df
            return df
        except:
            _season_cache[key] = None
            return None

    key = (div, season)
    if key in _season_cache:
        return _season_cache[key]
    url = f'https://www.football-data.co.uk/mmz4281/{season}/{div}.csv'
    tmp = rf'E:\soccer\_tmp_{div}_{season}.csv'
    try:
        urllib.request.urlretrieve(url, tmp)
        df = pd.read_csv(tmp, encoding='utf-8-sig')
        df['Date'] = pd.to_datetime(df['Date'], dayfirst=True, errors='coerce')
        _season_cache[key] = df
        return df
    except:
        _season_cache[key] = None
        return None

# ── Exchange-sourced results (added 2026-08-07) ───────────────────────────
# Settlement only ever uses FTR: `won = (ftr == bet_side[0])` below. The score
# string is cosmetic -- it decorates emails and the stored record. That matters
# because the exchanges we already talk to publish the RESULT even though
# neither publishes a scoreline we can always reconstruct:
#
#   Kalshi   `result` = yes/no per leg on a finalized market -> FTR directly.
#   Poly-US  `score` ("0-1") + `ended` on the event -> FTR and a real scoreline.
#   Poly.com resolved `outcomePrices` (1/0 per outcome) -> FTR.
#
# All three are free and off the API-Football quota, which dropped from
# 75,000/day to 7,500/day on 2026-08-07.
#
# Every one of these returns (None, '') unless it is CERTAIN. A wrong result
# silently books a win as a loss in the frozen forward test, so an ambiguous
# read must fall through to the next source rather than guess.

_NAME_STOP = {'fc', 'cf', 'ac', 'sc', 'afc', 'the', 'vs', 'club', 'deportivo'}


def _name_tokens(s):
    """Word tokens for fuzzy fixture matching, accent-folded, noise removed.
    Deliberately the same shape as maps._tokens -- substring matching on
    space-stripped names caused three separate wrong-club bugs."""
    import re
    import unicodedata
    if not s:
        return set()
    nfkd = unicodedata.normalize('NFKD', str(s))
    flat = ''.join(c for c in nfkd if not unicodedata.combining(c)).lower()
    return {t for t in re.split(r'[^a-z0-9]+', flat)
            if len(t) >= 4 and t not in _NAME_STOP}


def _ftr_from_goals(hg, ag):
    if hg is None or ag is None:
        return None
    return 'H' if hg > ag else ('A' if ag > hg else 'D')


def fetch_result_kalshi(bet):
    """Final result from Kalshi settlement. Returns (ftr, score) or (None, '').

    Kalshi has no scoreline, so `score` comes back '' -- callers must treat it
    as cosmetic. Requires ALL THREE legs finalized with an unambiguous yes/no,
    and exactly one `yes`. Anything else (a `scalar` result, two winners, a
    partially-settled event) falls through: observed on 2026-08-07 that one
    settled event returned result='scalar' on all three legs.
    """
    try:
        import kalshi_live as KLV
        div = (bet.get('div') or '').upper()
        series = maps.league_kalshi_series(div)
        if not series:
            return None, ''
        ha, aa = KLV.get_abr(bet.get('home')), KLV.get_abr(bet.get('away'))
        if not (ha and aa):
            return None, ''
        try:
            gd = datetime.fromisoformat(str(bet['date'])[:10]).date()
        except Exception:
            return None, ''
        tickers = [KLV.build_event_ticker(series, gd, ha, aa),
                   KLV.build_event_ticker(series, gd, aa, ha)]
        for tk in tickers:
            r = requests.get(
                f'https://api.elections.kalshi.com/trade-api/v2/events/{tk}',
                params={'with_nested_markets': 'true'}, timeout=20)
            if r.status_code != 200:
                continue
            markets = (r.json().get('event') or {}).get('markets') or []
            if len(markets) != 3:
                continue
            if any(m.get('status') not in ('finalized', 'settled') for m in markets):
                return None, ''          # not settled yet -- do NOT guess
            winners = [m for m in markets if str(m.get('result')).lower() == 'yes']
            losers = [m for m in markets if str(m.get('result')).lower() == 'no']
            if len(winners) != 1 or len(losers) != 2:
                return None, ''          # 'scalar' or malformed -- fall through
            suffix = winners[0]['ticker'].rsplit('-', 1)[-1].upper()
            if suffix == ha:
                return 'H', ''
            if suffix == aa:
                return 'A', ''
            return 'D', ''               # the third leg is the tie
        return None, ''
    except Exception as e:
        print(f"  [Kalshi resolve] {bet.get('home')} v {bet.get('away')}: {e}")
        return None, ''


def fetch_result_polyus(bet):
    """Final result from Poly-US event state. Returns (ftr, score) or (None, '').

    Poly-US carries a real scoreline ('0-1') plus `ended`/`live`, verified live
    on 2026-08-07 against API-Football mid-match. Requires `ended` -- a score
    read while `live` is still true would settle the bet at a partial score.

    LIMITATION (measured 2026-08-07): /v2/leagues/<slug>/events lists only
    UPCOMING fixtures -- a finished match drops off within hours. So this fires
    only for games that ended very recently, and returns None for anything the
    nightly resolver is typically looking at. It is kept in the chain because
    it costs nothing when it misses and it is the only free source of an actual
    SCORELINE; capturing the score at FT during live monitoring (rather than at
    resolution time) is what would make it dependable.
    """
    try:
        import poly_us_prices as PUS
        e = PUS.find_event((bet.get('div') or '').upper(),
                           bet.get('home'), bet.get('away'))
        if not e:
            return None, ''
        if not e.get('ended') or e.get('live'):
            return None, ''
        raw = (e.get('score') or (e.get('eventState') or {}).get('score') or '')
        parts = str(raw).split('-')
        if len(parts) != 2:
            return None, ''
        hg, ag = int(parts[0].strip()), int(parts[1].strip())
        ftr = _ftr_from_goals(hg, ag)
        return (ftr, f'{hg}-{ag}') if ftr else (None, '')
    except Exception as e:
        print(f"  [Poly-US resolve] {bet.get('home')} v {bet.get('away')}: {e}")
        return None, ''


def fetch_result_polycom(bet):
    """Final result from Polymarket.com resolution. Returns (ftr, score) or (None, '').

    A resolved gamma market prices its winning outcome at 1 and the rest at 0.
    Requires the event closed with exactly one outcome at 1 across the three
    match-winner markets, so a partially-resolved or disputed event falls
    through rather than resolving on a stale mid-price.
    """
    try:
        # NOT polymarket_live.find_event -- that only searches OPEN events and
        # returns None for anything already settled, which is every bet we are
        # resolving. Query gamma for CLOSED events under the league tag instead.
        tag = (maps.league_map_full().get((bet.get('div') or '').upper()) or {}).get('poly_tag')
        if not tag:
            return None, ''
        want = _name_tokens(bet.get('home')) | _name_tokens(bet.get('away'))
        if len(want) < 2:
            return None, ''
        e = None
        for offset in (0, 100, 200):
            r = requests.get('https://gamma-api.polymarket.com/events',
                             params={'tag_slug': tag, 'closed': 'true',
                                     'limit': 100, 'offset': offset}, timeout=20)
            batch = r.json()
            if not isinstance(batch, list) or not batch:
                break
            for cand in batch:
                if str(bet.get('date'))[:10] not in str(cand.get('slug', '')):
                    continue
                got = _name_tokens(cand.get('title'))
                # require BOTH teams to be recognisable, not just one -- a
                # single-team match happily picks the wrong fixture
                if len(want & got) >= 2:
                    e = cand
                    break
            if e or len(batch) < 100:
                break
        if not e or not e.get('closed'):
            return None, ''
        won = []
        for m in (e.get('markets') or []):
            try:
                prices = json.loads(m.get('outcomePrices') or '[]')
            except Exception:
                continue
            if prices and float(prices[0]) >= 0.99:
                won.append((m.get('groupItemTitle') or m.get('question') or '').lower())
        if len(won) != 1:
            return None, ''
        label = won[0]
        home_l, away_l = str(bet.get('home')).lower(), str(bet.get('away')).lower()
        if 'draw' in label or 'tie' in label:
            return 'D', ''
        if any(t in label for t in home_l.split()[:2] if len(t) > 3):
            return 'H', ''
        if any(t in label for t in away_l.split()[:2] if len(t) > 3):
            return 'A', ''
        return None, ''
    except Exception as e:
        print(f"  [Poly.com resolve] {bet.get('home')} v {bet.get('away')}: {e}")
        return None, ''


def fetch_result_af(fixture_id):
    """Fetch final result from API-Football by fixture ID. Returns (ftr, score) or (None, '')."""
    if not fixture_id:
        return None, ''
    try:
        results = af_get('fixtures', {'id': fixture_id})
        if not results:
            return None, ''
        fix = results[0]
        status = fix['fixture']['status']['short']
        if status not in ('FT', 'AET', 'PEN'):
            return None, ''
        hg = fix['goals']['home']
        ag = fix['goals']['away']
        if hg is None or ag is None:
            return None, ''
        if hg > ag:
            ftr = 'H'
        elif ag > hg:
            ftr = 'A'
        else:
            ftr = 'D'
        return ftr, f'{hg}-{ag}'
    except Exception as e:
        print(f'  [AF resolve] fixture {fixture_id}: {e}')
        return None, ''

# API-Football short statuses where the match was NOT played to a natural finish.
# A bet on such a fixture is VOID (stake returned, no P&L on any book), not a loss.
# Maps the code -> human label used in logs/emails.
VOID_STATUSES = {
    'PST':  'Postponed',
    'CANC': 'Cancelled',
    'ABD':  'Abandoned',
    'WO':   'Walkover',
    'AWD':  'Awarded',
}

def fetch_status_af(fixture_id):
    """Return the API-Football short status (e.g. 'FT','PST','CANC') or None.
    Used to distinguish a void/postponed match from one whose result just isn't in yet."""
    if not fixture_id:
        return None
    try:
        results = af_get('fixtures', {'id': fixture_id})
        if not results:
            return None
        return results[0]['fixture']['status']['short']
    except Exception as e:
        print(f'  [AF status] fixture {fixture_id}: {e}')
        return None

_live_data_cache = {}

def fetch_result_live_data(bet):
    """Look up final result from live_data/YYYY-MM-DD.csv. Returns (ftr, score) or (None, '')."""
    date_str = bet.get('date', '')[:10]
    if not date_str:
        return None, ''
    path = os.path.join(LIVE_DATA_DIR, f'{date_str}.csv')
    if not os.path.exists(path):
        return None, ''
    if date_str not in _live_data_cache:
        try:
            _live_data_cache[date_str] = pd.read_csv(path, encoding='utf-8')
        except Exception:
            return None, ''
    df = _live_data_cache[date_str]
    ft = df[df['status'].isin(['FT', 'AET', 'PEN'])]
    if ft.empty:
        return None, ''
    home_n = bet['home'].lower()
    away_n = bet['away'].lower()
    mask = (ft['home'].str.lower() == home_n) & (ft['away'].str.lower() == away_n)
    rows = ft[mask]
    if rows.empty:
        # fuzzy: substring both ways
        mask = (ft['home'].str.lower().apply(lambda x: x in home_n or home_n in x) &
                ft['away'].str.lower().apply(lambda x: x in away_n or away_n in x))
        rows = ft[mask]
    if rows.empty:
        return None, ''
    row = rows.iloc[-1]
    hg, ag = row['home_goals'], row['away_goals']
    try:
        hg, ag = int(hg), int(ag)
    except (ValueError, TypeError):
        return None, ''
    ftr = 'H' if hg > ag else ('A' if ag > hg else 'D')
    return ftr, f'{hg}-{ag}'


def resolve_pending_bets():
    pending = load_pending()
    if not pending:
        return []

    bankroll        = load_bankroll()
    kalshi_bankroll = load_kalshi_bankroll()
    poly_bankroll   = load_poly_bankroll()
    history         = load_history()
    season   = current_season()
    y = datetime.now().year
    prior_season = f"{y-2}{str(y-1)[2:]}" if datetime.now().month < 7 else f"{y-1}{str(y)[2:]}"
    resolved = []
    still_pending = []
    today = datetime.now(timezone.utc).date()

    for bet in pending:
        ftr = None
        score = ''

        # Resolution chain, cheapest and most authoritative first (2026-08-07).
        # API-Football moved from FIRST fallback to LAST, because the plan
        # dropped to 7,500 calls/day and the exchanges publish the same FTR for
        # free. Each source returns (None,'') unless certain, so an ambiguous
        # read falls through instead of booking a wrong result.
        src = None

        # 0. live_data CSV -- a LOCAL FILE READ, zero API calls of any kind, so
        #    it stays ahead of the free-but-networked exchange lookups. Its
        #    scores are API-Football-derived data we have already paid for.
        ftr, score = fetch_result_live_data(bet)
        if ftr is not None:
            src = 'live_data'

        # 1. Kalshi settlement -- the authority for a bet we settle on Kalshi.
        if ftr is None:
            ftr, score = fetch_result_kalshi(bet)
            if ftr is not None:
                src = 'kalshi'

        # 2. Poly-US -- carries a real scoreline, so it also restores `score`.
        if ftr is None:
            ftr, score = fetch_result_polyus(bet)
            if ftr is not None:
                src = 'polyus'

        # 3. Polymarket.com resolution.
        if ftr is None:
            ftr, score = fetch_result_polycom(bet)
            if ftr is not None:
                src = 'poly.com'

        # 4. API-Football LAST -- the only metered source in the chain.
        if ftr is None:
            ftr, score = fetch_result_af(bet.get('fixture_id'))
            if ftr is not None:
                src = 'api-football'

        if src:
            print(f"  [resolve] {bet.get('home')} v {bet.get('away')}: {ftr} via {src}")

        # 2. Fall back to football-data.co.uk CSVs
        if ftr is None:
            div = bet['div']
            seasons_to_try = ['all'] if div == 'MLS' else [season, prior_season]
            for s in seasons_to_try:
                df = fetch_season_df(div, s)
                if df is None:
                    continue
                home_f = fdco_name(bet['home'])
                away_f = fdco_name(bet['away'])
                try:
                    bet_date = pd.to_datetime(bet['date'][:10])
                except:
                    bet_date = None
                mask = (df['HomeTeam'] == home_f) & (df['AwayTeam'] == away_f)
                if bet_date is not None and 'Date' in df.columns:
                    mask = mask & (df['Date'] >= bet_date)
                match_rows = df[mask]
                if match_rows.empty:
                    fz = df[
                        df['HomeTeam'].str.lower().apply(lambda x: home_f.lower() in x or x in home_f.lower()) &
                        df['AwayTeam'].str.lower().apply(lambda x: away_f.lower() in x or x in away_f.lower())
                    ]
                    if bet_date is not None and 'Date' in df.columns:
                        fz = fz[fz['Date'] >= bet_date]
                    if not fz.empty:
                        match_rows = fz
                if match_rows.empty:
                    continue
                row = match_rows.iloc[-1]
                r = row.get('FTR', None)
                if pd.notna(r) and str(r).strip() != '':
                    ftr = str(r).strip()
                    score = f"{int(row.get('FTHG',0))}-{int(row.get('FTAG',0))}"
                    break

        try:
            bet_date = datetime.strptime(bet['date'][:10], '%Y-%m-%d').date()
        except:
            bet_date = today

        if ftr is None:
            # Was the match called off (postponed/cancelled/abandoned/...)? Void it —
            # refund the stake, no P&L on any book — instead of letting it sit and then
            # expire into a phantom loss after 10 days.
            # Grace period: only void once the game is >=2 days old. API-Football can
            # report PST transiently around kickoff and later correct it to FT (this
            # bit us on Sporting JAX vs Charleston 2026-06-20: voided, then AF showed
            # 2-5 FT). Waiting 2 days lets a real result land before we void.
            vstatus = fetch_status_af(bet.get('fixture_id'))
            if vstatus in VOID_STATUSES and (today - bet_date).days >= 2:
                result_entry = {**bet, 'ftr': 'VOID', 'won': None, 'voided': True,
                                'void_reason': VOID_STATUSES[vstatus],
                                'pnl': 0, 'bankroll_after': bankroll,
                                'resolved_at': today.isoformat()}
                history.append(result_entry)
                resolved.append(result_entry)
                print(f"  VOID: {bet['home']} vs {bet['away']} "
                      f"({VOID_STATUSES[vstatus]}) -- stake returned, no P&L")
                continue
            if (today - bet_date).days > 10:
                pnl = -round(bet.get('stake', 0), 2)
                bankroll = round(bankroll + pnl, 2)
                result_entry = {**bet, 'ftr': 'EXP', 'won': False, 'pnl': pnl,
                                'bankroll_after': bankroll, 'resolved_at': today.isoformat()}
                history.append(result_entry)
                resolved.append(result_entry)
                print(f"  EXPIRED: {bet['home']} vs {bet['away']} (no result after 10d) "
                      f"P&L={pnl:+.2f} -> Bankroll={bankroll:,.2f}")
            else:
                still_pending.append(bet)
            continue

        bet_side = bet.get('bet', 'Away')
        won = (ftr == bet_side[0])  # 'Away'->'A', 'Home'->'H'
        b365_stake = bet.get('stake')
        if b365_stake:
            pnl = round(b365_stake * (bet.get('b365', 1) - 1), 2) if won else -round(b365_stake, 2)
            bankroll = round(bankroll + pnl, 2)
        else:
            pnl = 0

        # ONE BET, ONE VENUE (fixed 2026-08-01).
        #
        # These two blocks used to be independent `if`s, so a signal carrying
        # BOTH stakes was paid out TWICE -- once at each book's price. That is
        # not two strategies, it is one signal double-counted: xlow names a
        # single cheap book, but live_monitor also writes a Kalshi fallback
        # stake because Polymarket.com cannot be traded from the US
        # (live_monitor ~line 524). Result: nightly rows like
        # "+100.12 (K:+63.31 P:+36.81)" for a single call. 41 historical bets
        # were affected; repaired by research/fix_dual_stake_ledger.py.
        #
        # Rule: book the CHEAPER ask, which is the book xlow actually called and
        # what research/forward_test_unique_book.py has always used -- so the
        # ledger and the forward test finally agree. Ties go to Kalshi, the
        # executable venue.
        k_ok = bool(bet.get('kalshi_stake') and bet.get('kalshi_ask'))
        p_ok = bool(bet.get('poly_stake') and bet.get('poly_ask'))
        if k_ok and p_ok:
            if float(bet['poly_ask']) < float(bet['kalshi_ask']) - 1e-9:
                k_ok = False
            else:
                p_ok = False

        kalshi_pnl = None
        if k_ok:
            k_payout = (1 - bet['kalshi_ask']) / bet['kalshi_ask']
            kalshi_pnl = round(bet['kalshi_stake'] * k_payout, 2) if won else -round(bet['kalshi_stake'], 2)
            kalshi_bankroll = round(kalshi_bankroll + kalshi_pnl, 2)

        poly_pnl = None
        if p_ok:
            p_payout = (1 - bet['poly_ask']) / bet['poly_ask']
            poly_pnl = round(bet['poly_stake'] * p_payout, 2) if won else -round(bet['poly_stake'], 2)
            poly_bankroll = round(poly_bankroll + poly_pnl, 2)

        result_entry = {**bet, 'ftr': ftr, 'won': won, 'pnl': pnl,
                        'bankroll_after': bankroll, 'resolved_at': today.isoformat(),
                        'score': score}
        if kalshi_pnl is not None:
            result_entry['kalshi_pnl'] = kalshi_pnl
            result_entry['kalshi_bankroll_after'] = kalshi_bankroll
        if poly_pnl is not None:
            result_entry['poly_pnl'] = poly_pnl
            result_entry['poly_bankroll_after'] = poly_bankroll
        history.append(result_entry)
        resolved.append(result_entry)
        k_str = f' | Kalshi P&L={kalshi_pnl:+.2f} -> K.Bankroll={kalshi_bankroll:,.2f}' if kalshi_pnl is not None else ''
        k_str += f' | Poly P&L={poly_pnl:+.2f} -> P.Bankroll={poly_bankroll:,.2f}' if poly_pnl is not None else ''
        print(f"  Resolved: {bet['home']} vs {bet['away']} ({score}) "
              f"{'WIN' if won else 'LOSS'} P&L={pnl:+.2f} -> Bankroll={bankroll:,.2f}{k_str}")

    save_bankroll(bankroll)
    save_kalshi_bankroll(kalshi_bankroll)
    save_poly_bankroll(poly_bankroll)
    save_pending(still_pending)
    save_history(history)
    return resolved

# ── CLV calculation ──────────────────────────────────────────────────────
def remove_margin(h, d, a):
    over = 1/h + 1/d + 1/a
    return 1/h/over, 1/d/over, 1/a/over

def committed_amount():
    """Total stakes tied up in unresolved pending bets."""
    return sum(b.get('stake', 0) for b in load_pending())

def af_get(endpoint, params, retries=3):
    """API-Football GET with retry."""
    url = f'{AF_BASE}/{endpoint}'
    for attempt in range(retries):
        try:
            r = requests.get(url, headers=AF_HEADERS, params=params, timeout=15)
            r.raise_for_status()
            return r.json().get('response', [])
        except Exception as e:
            if attempt < retries - 1:
                time.sleep(2 ** attempt)
            else:
                print(f'  [AF] Error {endpoint} {params}: {e}')
                return []

def fetch_fixtures_with_odds():
    """
    Pull today's fixtures + Pinnacle/B365 odds from API-Football.
    Returns list of dicts with all fields needed for CLV calculation.
    """
    today = datetime.now(timezone.utc).date()
    dates = [today.isoformat()]

    # Step 1: collect odds per league per date
    # odds response: {fixture:{id,date}, bookmakers:[{id,name,bets:[{name,values}]}]}
    raw = []  # list of (fixture_id, date_str, div, pin_h, pin_d, pin_a, b365_h, b365_d, b365_a)
    total_calls = 0
    for div in LEAGUES:
        league_id = maps.league_api_id(div)
        if not league_id:
            continue
        # Ask API-Football which season it considers current (cached 7d) rather
        # than trusting the league_map pin -- a stale pin fetches odds for a
        # FINISHED season and returns nothing, with no error. See maps.py.
        season = maps.league_current_season(div, AF_HEADERS, AF_SEASON)
        for date_str in dates:
            results = af_get('odds', {'league': league_id, 'season': season, 'date': date_str})
            total_calls += 1
            for fix in results:
                fid = fix['fixture']['id']
                fdate = fix['fixture']['date'][:10]
                ftime = fix['fixture']['date'][11:16]  # HH:MM UTC
                pin_h = pin_d = pin_a = None
                b365_h = b365_d = b365_a = None
                for bk in fix.get('bookmakers', []):
                    for bet in bk.get('bets', []):
                        if bet['name'] != 'Match Winner':
                            continue
                        vals = {v['value']: float(v['odd']) for v in bet['values']}
                        h = vals.get('Home'); d_ = vals.get('Draw'); a = vals.get('Away')
                        if not all([h, d_, a]):
                            continue
                        if bk['id'] == AF_PIN_ID:
                            pin_h, pin_d, pin_a = h, d_, a
                        elif bk['id'] == AF_B365_ID:
                            b365_h, b365_d, b365_a = h, d_, a
                if pin_h and b365_a:
                    raw.append({
                        'fixture_id': fid,
                        'date': fdate,
                        'time': ftime,
                        'div': div,
                        'pin_h': pin_h, 'pin_d': pin_d, 'pin_a': pin_a,
                        'b365_h': b365_h, 'b365_d': b365_d, 'b365_a': b365_a,
                    })

    # ── Supplement: leagues where per-fixture query works but league query doesn't (e.g. MLS) ──
    FIXTURE_QUERY_DIVS = {'MLS'}
    seen_fids = {r['fixture_id'] for r in raw}
    wl_path = r'E:\soccer\data\watchlist.txt'
    if os.path.exists(wl_path):
        wl_date = today.isoformat()
        with open(wl_path, encoding='utf-8') as _f:
            for _line in _f:
                _line = _line.strip()
                if _line.startswith('DATE='):
                    wl_date = _line[5:].strip()
                    continue
                if not _line or _line.startswith('#'):
                    continue
                parts = [p.strip() for p in _line.split('|')]
                if len(parts) < 5:
                    continue
                _home, _away, _lc, _ko, _fid_s = parts[0], parts[1], parts[2], parts[3], parts[4]
                if _lc not in FIXTURE_QUERY_DIVS or not _fid_s.isdigit():
                    continue
                if wl_date != today.isoformat():
                    continue
                _fid = int(_fid_s)
                if _fid in seen_fids:
                    continue
                seen_fids.add(_fid)
                _results = af_get('odds', {'fixture': _fid})
                total_calls += 1
                for fix in _results:
                    _pin_h = _pin_d = _pin_a = None
                    _b365_h = _b365_d = _b365_a = None
                    for bk in fix.get('bookmakers', []):
                        for bet in bk.get('bets', []):
                            if bet['name'] != 'Match Winner':
                                continue
                            vals = {v['value']: float(v['odd']) for v in bet['values']}
                            h = vals.get('Home'); d_ = vals.get('Draw'); a = vals.get('Away')
                            if not all([h, d_, a]):
                                continue
                            if bk['id'] == AF_PIN_ID:
                                _pin_h, _pin_d, _pin_a = h, d_, a
                            elif bk['id'] == AF_B365_ID:
                                _b365_h, _b365_d, _b365_a = h, d_, a
                    if _pin_h and _b365_h:
                        raw.append({
                            'fixture_id': _fid,
                            'date': today.isoformat(),
                            'time': _ko,
                            'div': _lc,
                            'home': _home,
                            'away': _away,
                            'pin_h': _pin_h, 'pin_d': _pin_d, 'pin_a': _pin_a,
                            'b365_h': _b365_h, 'b365_d': _b365_d, 'b365_a': _b365_a,
                            '_name_from_wl': True,  # skip fixture info lookup
                        })

    print(f'  API calls: {total_calls} | Fixtures with Pin+B365: {len(raw)}')

    if not raw:
        return []

    # Step 2: get team names for fixture IDs that don't already have them
    needs_lookup = [r['fixture_id'] for r in raw if not r.get('_name_from_wl')]
    fids = list(dict.fromkeys(needs_lookup))
    fixture_info = {}  # fid -> {home, away}
    chunk_size = 20
    for i in range(0, len(fids), chunk_size):
        chunk = fids[i:i+chunk_size]
        ids_str = '-'.join(str(f) for f in chunk)
        results = af_get('fixtures', {'ids': ids_str})
        for fix in results:
            fid = fix['fixture']['id']
            fixture_info[fid] = {
                'home': fix['teams']['home']['name'],
                'away': fix['teams']['away']['name'],
            }

    # Step 3: build signal candidates
    seen = set()
    candidates = []
    for r in raw:
        fid = r['fixture_id']
        if fid in seen:
            continue
        if not r.get('_name_from_wl') and fid not in fixture_info:
            continue
        seen.add(fid)
        if not r.get('_name_from_wl'):
            info = fixture_info[fid]
            r['home'] = info['home']
            r['away'] = info['away']
        candidates.append(r)

    return candidates

def fetch_kalshi_signal(home, away, div, game_date_str):
    """Fetch Kalshi away price for a signal. Returns (mid, ask) or (None, None)."""
    try:
        from kalshi_live import get_all_prices
        from datetime import date as date_
        series = maps.league_kalshi_series(div.upper())
        if not series:
            return None, None
        gd = date_.fromisoformat(game_date_str)
        result = get_all_prices(series, gd, home, away)
        if result and result.get('away_mid') is not None:
            return result['away_mid'], result.get('away_ask')
        return None, None
    except:
        return None, None

def fetch_poly_signal(home, away, div, game_date_str, side):
    """Fetch Polymarket price for a signal side ('home'/'away'). Returns (mid, ask) or (None, None)."""
    try:
        import polymarket_live
        from datetime import date as date_
        gd = date_.fromisoformat(game_date_str)
        result = polymarket_live.get_all_prices(gd, home, away, league=div.upper())
        if result and result.get(f'{side}_mid') is not None:
            return result[f'{side}_mid'], result.get(f'{side}_ask')
        return None, None
    except:
        return None, None

def scan_fixtures(bankroll, kalshi_bankroll):
    """Fetch today's fixtures from API-Football and scan for CLV signals.
    Away CLV >5% | Home CLV >8% (both vs Pinnacle closing fair).
    """
    available        = max(bankroll - committed_amount(), 0)
    kalshi_pending   = sum(b.get('kalshi_stake', 0) for b in load_pending())
    kalshi_available = max(kalshi_bankroll - kalshi_pending, 0)
    poly_bankroll    = load_poly_bankroll()
    poly_pending     = sum(b.get('poly_stake', 0) for b in load_pending())
    poly_available   = max(poly_bankroll - poly_pending, 0)

    candidates = fetch_fixtures_with_odds()
    if not candidates:
        return [], False

    signals = []
    for r in candidates:
        pin_h, pin_d, pin_a = r['pin_h'], r['pin_d'], r['pin_a']
        tp_h, tp_d, tp_a = remove_margin(pin_h, pin_d, pin_a)

        # Check Away CLV >5% and Home CLV >8%
        bets_to_check = []
        edge_a = (r['b365_a'] - 1/tp_a) / (1/tp_a)
        edge_h = (r['b365_h'] - 1/tp_h) / (1/tp_h)
        if EDGE_THRESHOLD_AWAY <= edge_a < EDGE_UPPER_CAP_AWAY:
            bets_to_check.append(('Away', r['b365_a'], tp_a, edge_a))
        if EDGE_THRESHOLD_HOME <= edge_h < EDGE_UPPER_CAP_HOME:
            bets_to_check.append(('Home', r['b365_h'], tp_h, edge_h))

        # If both Home and Away fire on the same match, keep only the
        # higher-CLV side (both firing == synthetic NO DRAW call; smaller
        # CLV is noise on the same underlying mispricing). 2026-05-23.
        if len(bets_to_check) == 2:
            winner = max(bets_to_check, key=lambda x: x[3])
            loser  = min(bets_to_check, key=lambda x: x[3])
            print(f"  [DEDUPE] {r['home']} vs {r['away']}: both sides fired — "
                  f"keeping {winner[0]} ({winner[3]*100:+.1f}%) over "
                  f"{loser[0]} ({loser[3]*100:+.1f}%)")
            bets_to_check = [winner]

        for bet_side, b365, p, edge in bets_to_check:
            b = b365 - 1
            kelly_f = max((p * b - (1 - p)) / b, 0)
            if kelly_f <= 0:
                continue

            stake_frac = min(kelly_f * KELLY_MULT, MAX_BET_PCT)
            stake = round(stake_frac * available, 2)
            if stake < 1.0:
                continue

            # Kalshi price lookup (Away only — Kalshi away market)
            kalshi_mid, kalshi_ask = None, None
            kalshi_stake = kalshi_kelly_f = kalshi_edge_pct = None
            if bet_side == 'Away':
                kalshi_mid, kalshi_ask = fetch_kalshi_signal(r['home'], r['away'], r['div'], r['date'])
                if kalshi_ask and kalshi_ask > 0:
                    k_b = (1 - kalshi_ask) / kalshi_ask
                    k_kelly_f = max((p * k_b - (1 - p)) / k_b, 0)
                    k_stake_frac = min(k_kelly_f * KELLY_MULT, MAX_BET_PCT)
                    # Compound off the live Kalshi tracker (committed-capital adjusted),
                    # mirroring the B365 leg's `available`. Was fixed $1000 notional.
                    k_stake = round(k_stake_frac * kalshi_available, 2)
                    if k_stake >= 1.0:
                        kalshi_stake    = k_stake
                        kalshi_kelly_f  = round(k_kelly_f, 4)
                        kalshi_edge_pct = round((p - kalshi_ask) / kalshi_ask * 100, 1)

            # Polymarket price lookup (both sides — Poly has home & away markets)
            poly_mid, poly_ask = None, None
            poly_stake = poly_kelly_f = poly_edge_pct = None
            poly_side = 'home' if bet_side == 'Home' else 'away'
            poly_mid, poly_ask = fetch_poly_signal(r['home'], r['away'], r['div'], r['date'], poly_side)
            if poly_ask and poly_ask > 0:
                p_b = (1 - poly_ask) / poly_ask
                p_kelly_f = max((p * p_b - (1 - p)) / p_b, 0)
                p_stake_frac = min(p_kelly_f * KELLY_MULT, MAX_BET_PCT)
                p_stake = round(p_stake_frac * poly_available, 2)
                if p_stake >= 1.0:
                    poly_stake    = p_stake
                    poly_kelly_f  = round(p_kelly_f, 4)
                    poly_edge_pct = round((p - poly_ask) / poly_ask * 100, 1)

            signals.append({
                'date':            r['date'],
                'time':            r['time'] + ' UTC',
                'league':          LEAGUE_NAMES.get(r['div'], r['div']),
                'div':             r['div'],
                'fixture_id':      r['fixture_id'],
                'home':            r['home'],
                'away':            r['away'],
                'bet':             bet_side,
                'b365':            round(b365, 2),
                'fair':            round(1/p, 2),
                'edge_pct':        round(edge * 100, 1),
                'kelly_f':         round(kelly_f, 4),
                'kelly_pct':       round(stake_frac * 100, 1),
                'stake':           stake,
                'p_win':           round(p, 4),
                'pin_source':      'API-Football/Pinnacle',
                'kalshi_mid':      round(kalshi_mid, 4) if kalshi_mid else None,
                'kalshi_ask':      round(kalshi_ask, 4) if kalshi_ask else None,
                'kalshi_edge_pct': kalshi_edge_pct,
                'kalshi_kelly_f':  kalshi_kelly_f,
                'kalshi_stake':    kalshi_stake,
                'poly_mid':        round(poly_mid, 4) if poly_mid else None,
                'poly_ask':        round(poly_ask, 4) if poly_ask else None,
                'poly_edge_pct':   poly_edge_pct,
                'poly_kelly_f':    poly_kelly_f,
                'poly_stake':      poly_stake,
            })

    return signals, True  # True = odds were available

# ── Email ────────────────────────────────────────────────────────────────
def bankroll_summary_html(bankroll, kalshi_bankroll):
    """Build a small status bar showing bankroll health."""
    history = load_history()
    pending = load_pending()
    poly_bankroll    = load_poly_bankroll()
    committed        = sum(b.get('stake', 0) for b in pending)
    kalshi_committed = sum(b.get('kalshi_stake', 0) for b in pending if b.get('kalshi_stake'))
    poly_committed   = sum(b.get('poly_stake', 0) for b in pending if b.get('poly_stake'))
    if not history:
        return f"""<div style="background:#f0f0f0;padding:10px;margin-bottom:15px;border-radius:5px;font-size:12px">
        <b>Kalshi Bankroll:</b> ${kalshi_bankroll:,.2f} (${kalshi_committed:,.2f} committed) |
        <b>Poly Bankroll:</b> ${poly_bankroll:,.2f} (${poly_committed:,.2f} committed) |
        <b>Pending:</b> {len(pending)} bets | <b>Record:</b> 0W-0L | <b>P&L:</b> $0.00</div>"""
    wins = sum(1 for h in history if h.get('won'))
    losses = sum(1 for h in history if not h.get('won') and not h.get('voided'))
    b365_pnl   = sum(h.get('pnl', 0) for h in history)
    kalshi_pnl = sum(h.get('kalshi_pnl', 0) for h in history if h.get('kalshi_pnl') is not None)
    poly_pnl   = sum(h.get('poly_pnl', 0) for h in history if h.get('poly_pnl') is not None)
    total_staked = sum(h.get('stake', 0) for h in history)
    roi = (b365_pnl / total_staked * 100) if total_staked > 0 else 0
    pnl_color = '#2d7d2d' if b365_pnl >= 0 else '#cc3333'
    k_color   = '#2d7d2d' if kalshi_pnl >= 0 else '#cc3333'
    p_color   = '#2d7d2d' if poly_pnl >= 0 else '#cc3333'
    # B365 dropped from display 2026-08-09 with the baseline strategy (see
    # B365_BETTING_ENABLED). It is a bookmaker we cannot trade, so its bankroll
    # and P&L were never real money -- leaving them on screen invited reading a
    # phantom venue as performance. The figures remain in bet_history for the
    # settled bets; they are simply no longer reported as live.
    return f"""<div style="background:#f0f0f0;padding:10px;margin-bottom:15px;border-radius:5px;font-size:12px">
    <b>Kalshi:</b> ${kalshi_bankroll:,.2f} | <b>Poly:</b> ${poly_bankroll:,.2f} |
    <b>Pending:</b> {len(pending)} bets (Kalshi ${kalshi_committed:,.2f} / Poly ${poly_committed:,.2f}) |
    <b>Record:</b> {wins}W-{losses}L |
    <b>Kalshi P&L:</b> <span style="color:{k_color}">${kalshi_pnl:+,.2f}</span> |
    <b>Poly P&L:</b> <span style="color:{p_color}">${poly_pnl:+,.2f}</span></div>"""

def build_html(signals, today, bankroll, kalshi_bankroll=None, resolved=None, reason=None):
    status_bar = bankroll_summary_html(bankroll, kalshi_bankroll or load_kalshi_bankroll())
    resolved_html = ''
    if resolved:
        wins   = [r for r in resolved if r.get('won')]
        losses = [r for r in resolved if not r.get('won') and not r.get('voided')]
        voids  = [r for r in resolved if r.get('voided')]
        # `pnl` is the B365 leg. Excluded from the headline total 2026-08-09:
        # B365 is not tradeable, so counting it made the day's result part
        # phantom money -- and on baseline bets it double-counted a signal that
        # was ALSO staked on an exchange (AZ Alkmaar 2026-08-08: -3.73 B365 plus
        # -3.49 Poly for one bet). Still summed separately below so historical
        # rows remain auditable, just never folded into what we call the result.
        net        = sum(r.get('pnl', 0) for r in resolved)
        kalshi_net = sum(r.get('kalshi_pnl', 0) for r in resolved if r.get('kalshi_pnl') is not None)
        poly_net   = sum(r.get('poly_pnl', 0) for r in resolved if r.get('poly_pnl') is not None)
        total_net  = kalshi_net + poly_net
        sign       = '+' if total_net >= 0 else ''
        res_rows = ''
        for r in resolved:
            strategy = r.get('strategy', 'baseline')
            if r.get('voided'):
                # Match called off — show as a void, no P&L, neutral colour.
                res_rows += f"""
        <tr>
          <td>{r['date']}</td>
          <td>{r.get('league','')}</td>
          <td>{strategy}</td>
          <td>{r['home']} vs {r['away']}</td>
          <td></td>
          <td style="color:#888"><b>VOID</b></td>
          <td style="color:#888">{r.get('void_reason','void')} (stake returned)</td>
        </tr>"""
                continue
            icon = 'W' if r.get('won') else 'L'
            color = '#2d7d2d' if r.get('won') else '#cc3333'
            b365_pnl   = r.get('pnl', 0)
            k_pnl      = r.get('kalshi_pnl')
            p_pnl      = r.get('poly_pnl')
            combined   = b365_pnl + (k_pnl if k_pnl is not None else 0) + (p_pnl if p_pnl is not None else 0)
            pnl_sign   = '+' if combined >= 0 else ''
            # Show B365 odds only if there was a B365 stake
            odds_cell  = r.get('b365', '') if r.get('stake') else ''
            # Build P&L cell: show per-book breakdown when more than one book is present
            parts = []
            if r.get('stake'):
                parts.append(f"B:{b365_pnl:+.2f}")
            if k_pnl is not None:
                parts.append(f"K:{k_pnl:+.2f}")
            if p_pnl is not None:
                parts.append(f"P:{p_pnl:+.2f}")
            if len(parts) > 1:
                pnl_str = f"{pnl_sign}{combined:.2f} (" + " ".join(parts) + ")"
            else:
                pnl_str = f"{pnl_sign}{combined:.2f}"
            res_rows += f"""
        <tr>
          <td>{r['date']}</td>
          <td>{r.get('league','')}</td>
          <td>{strategy}</td>
          <td>{r['home']} vs {r['away']}</td>
          <td>{odds_cell}</td>
          <td style="color:{color}"><b>{icon}</b></td>
          <td style="color:{color}"><b>{pnl_str}</b></td>
        </tr>"""
        void_str = f" / {len(voids)} void" if voids else ""
        resolved_html = f"""
    <h3>Results ({len(wins)}W / {len(losses)}L{void_str}, {sign}{total_net:.2f})</h3>
    <table border="1" cellpadding="5" cellspacing="0" style="border-collapse:collapse;margin-bottom:20px">
      <tr style="background:#444;color:white">
        <th>Date</th><th>League</th><th>Strategy</th><th>Match</th><th>Odds</th><th>Result</th><th>P&L</th>
      </tr>
      {res_rows}
    </table>
    <p><b>Updated B365 bankroll: ${bankroll:,.2f} | Kalshi bankroll: ${kalshi_bankroll:,.2f} | Poly bankroll: ${load_poly_bankroll():,.2f}</b></p>
    <hr style="margin:20px 0">"""

    if not signals:
        return f"""
    <html><body style="font-family:Arial,sans-serif;font-size:13px">
    {status_bar}
    {resolved_html}
    <h2>No CLV Signals - {today}</h2>
    <p style="color:grey">{reason or 'No qualifying Away bets found.'}</p>
    <p style="color:grey;font-size:11px">
      Strategy: Away CLV >5% vs Pinnacle | {KELLY_MULT}x Kelly
    </p>
    </body></html>"""

    total_stake        = sum(s.get('stake', 0) for s in signals)
    total_kalshi_stake = sum(s['kalshi_stake'] for s in signals if s.get('kalshi_stake'))

    # Backfill auto-trade fields from the canonical log (orders may have been
    # placed by live_monitor between daily_signals scans).
    try:
        with open(AUTO_TRADE_LOG) as _f:
            _auto_log = json.load(_f)
        _auto_by_key = {(e.get('fixture_id'),
                         'away' if 'AWAY' in (e.get('client_order_id','')).upper() or
                                   e.get('client_order_id','').endswith('-away')
                                else 'home'): e
                        for e in _auto_log}
        for _s in signals:
            _bet_norm = 'away' if 'AWAY' in _s.get('bet','Away').upper() else 'home'
            _rec = _auto_by_key.get((_s.get('fixture_id'), _bet_norm))
            if _rec and not _s.get('auto_order_id'):
                _s['auto_order_id']    = _rec.get('order_id')
                _s['auto_count']       = _rec.get('count')
                _s['auto_price_cents'] = _rec.get('price_cents')
    except (FileNotFoundError, ValueError):
        pass

    rows = ''
    for s in signals:
        k_cell = 'N/A'
        fixed_k_str = ''
        if s.get('kalshi_ask'):
            k_color = '#2d7d2d' if (s.get('kalshi_edge_pct') or 0) > 0 else '#cc3333'
            k_stake_str = f'${s["kalshi_stake"]:.2f}' if s.get('kalshi_stake') else '-'
            k_mid = s.get('kalshi_mid')
            k_edge = s.get('kalshi_edge_pct')
            k_mid_str = f'mid={k_mid:.3f} ' if k_mid is not None else ''
            k_edge_str = f'edge={k_edge:+.1f}%' if k_edge is not None else ''
            k_cell = (f'<span style="color:{k_color}">{s["kalshi_ask"]:.3f} {k_mid_str}'
                      f'{k_edge_str}</span><br><b>{k_stake_str}</b>')
            if s.get('kalshi_kelly_f') is not None:
                fixed_k_stake = round(min(s['kalshi_kelly_f'] * KELLY_MULT, MAX_BET_PCT) * 1000, 2)
                fixed_k_str = f'${fixed_k_stake:.2f}'
        p_cell = 'N/A'
        if s.get('poly_ask'):
            p_color = '#2d7d2d' if (s.get('poly_edge_pct') or 0) > 0 else '#cc3333'
            p_stake_str = f'${s["poly_stake"]:.2f}' if s.get('poly_stake') else '-'
            p_mid = s.get('poly_mid')
            p_edge = s.get('poly_edge_pct')
            p_mid_str = f'mid={p_mid:.3f} ' if p_mid is not None else ''
            p_edge_str = f'edge={p_edge:+.1f}%' if p_edge is not None else ''
            p_cell = (f'<span style="color:{p_color}">{s["poly_ask"]:.3f} {p_mid_str}'
                      f'{p_edge_str}</span><br><b>{p_stake_str}</b>')
        bet_label = s.get('bet', 'Away')
        is_home = bet_label.startswith('H')
        b365_val = s.get('b365', '')
        fair_val = s.get('fair', '')
        edge_val = s.get('edge_pct', '')
        p_win_val = s.get('p_win', 0)
        kelly_pct_val = s.get('kelly_pct', '')
        stake_val = s.get('stake', 0)
        # Auto-trade column (Phase 3): show order id + fill price if auto-placed,
        # blank if not eligible / not enabled, red SKIP marker if hook ran and skipped.
        if s.get('auto_order_id'):
            auto_cell = (f'<span style="color:#2d7d2d">PLACED {s.get("auto_count","?")}@'
                         f'{s.get("auto_price_cents","?")}c<br>'
                         f'<span style="font-size:10px;color:grey">{s["auto_order_id"][:8]}</span></span>')
        elif s.get('div') in AUTO_TRADE_EXCLUDE_LEAGUES and s.get('kalshi_stake'):
            auto_cell = '<span style="color:#cc6600">PAPER</span>'
        elif AUTO_TRADE_ENABLED and (AUTO_TRADE_LEAGUES is None or s.get('div') in AUTO_TRADE_LEAGUES) and s.get('kalshi_stake'):
            auto_cell = '<span style="color:#cc3333">SKIPPED</span>'
        else:
            auto_cell = '<span style="color:#aaa">-</span>'
        rows += f"""
        <tr>
          <td>{s['date']}</td>
          <td>{s.get('time', '')}</td>
          <td>{s.get('league','')}</td>
          <td>{'<b>'+s['home']+'</b> vs '+s['away'] if is_home else s['home']+' vs <b>'+s['away']+'</b>'}</td>
          <td>{b365_val}</td>
          <td>{fair_val}</td>
          <td><b>{edge_val}{'%' if edge_val != '' else ''}</b></td>
          <td>{p_win_val*100:.1f}%</td>
          <td>{kelly_pct_val}{'%' if kelly_pct_val != '' else ''}</td>
          <td><b>${stake_val:.2f}</b></td>
          <td>{k_cell}</td>
          <td>{fixed_k_str}</td>
          <td>{p_cell}</td>
          <td>{auto_cell}</td>
        </tr>"""

    kalshi_line = f' | Kalshi total: <b>${total_kalshi_stake:,.2f}</b>' if total_kalshi_stake else ''
    return f"""
    <html><body style="font-family:Arial,sans-serif;font-size:13px">
    {status_bar}
    {resolved_html}
    <h2>{len(signals)} CLV Signal{'s' if len(signals)!=1 else ''} - {today}</h2>
    <p>B365 total stake: <b>${total_stake:,.2f}</b> ({total_stake/bankroll*100:.1f}% of bankroll){kalshi_line}</p>

    <table border="1" cellpadding="5" cellspacing="0" style="border-collapse:collapse;width:100%">
      <tr style="background:#222;color:white">
        <th>Date</th><th>Time</th><th>League</th><th>Match</th>
        <th>B365A</th><th>Fair</th><th>Edge</th><th>P(win)</th>
        <th>Kelly%</th><th>B365 Stake</th><th>Kalshi (ask/edge/stake)</th><th>Kalshi @$1k</th><th>Poly (ask/edge/stake)</th>
        <th>Auto</th>
      </tr>
      {rows}
    </table>

    <p style="color:grey;font-size:11px;margin-top:20px">
      Away CLV >5% vs Pinnacle | {KELLY_MULT}x Kelly | Max {MAX_BET_PCT*100:.0f}% per bet
    </p>
    </body></html>"""

def send_email(subject, html_body):
    if not EMAIL_ADDRESS or not EMAIL_APP_PASSWORD:
        print("  Email not configured (no .env). Skipping send.")
        return False
    try:
        msg = MIMEMultipart('alternative')
        msg['Subject'] = subject
        msg['From']    = EMAIL_ADDRESS
        msg['To']      = EMAIL_RECIPIENT
        msg.attach(MIMEText(html_body, 'html'))
        with smtplib.SMTP_SSL('smtp.gmail.com', 465) as server:
            server.login(EMAIL_ADDRESS, EMAIL_APP_PASSWORD)
            server.sendmail(EMAIL_ADDRESS, EMAIL_RECIPIENT, msg.as_string())
        return True
    except Exception as e:
        print(f"  EMAIL FAILED: {e}")
        return False

# ── Deduplication ────────────────────────────────────────────────────────
def load_sent():
    try:
        with open(SENT_LOG) as f:
            return json.load(f)
    except:
        return {}

def save_sent(data):
    with open(SENT_LOG, 'w') as f:
        json.dump(data, f, indent=2)

def signal_key(s):
    return f"{s['date']}|{s['home']}|{s['away']}"

def filter_new(signals, today):
    sent = load_sent()
    today_sent = set(sent.get(today, []))
    # Always include today's games; dedup only future-dated signals
    new = [s for s in signals if s.get('_reminder') or s.get('date') == today or signal_key(s) not in today_sent]
    return new, today_sent, sent

def mark_sent(signals, today, today_sent, sent):
    today_sent.update(signal_key(s) for s in signals)
    sent[today] = list(today_sent)
    cutoff = sorted(sent.keys())[-7:]
    sent = {k: sent[k] for k in cutoff}
    save_sent(sent)

# ── Auto-trade execution (Phase 3) ───────────────────────────────────────
def _load_auto_log():
    try:
        with open(AUTO_TRADE_LOG) as f:
            return json.load(f)
    except (FileNotFoundError, ValueError):
        return []


def _save_auto_log(data):
    with open(AUTO_TRADE_LOG, 'w') as f:
        json.dump(data, f, indent=2)


def _auto_log_error(msg):
    """Append a timestamped error to the auto-trade error log and email it.
    Throttled: max 1 email per 30 minutes to prevent flooding inbox if the
    same failure mode hits many signals in a row (e.g. Kalshi API outage).
    """
    line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n"
    try:
        with open(AUTO_TRADE_ERR_LOG, 'a') as f:
            f.write(line)
    except Exception:
        pass
    # Throttle: only email if last [AUTO-TRADE ERROR] email was >= 30 min ago.
    throttle_file = Path(r'E:\soccer\.auto_trade_err_last_email')
    now_ts = int(time.time())
    try:
        last_ts = int(throttle_file.read_text().strip())
    except Exception:
        last_ts = 0
    if now_ts - last_ts < 1800:
        return
    try:
        send_email("[AUTO-TRADE ERROR]", f"<pre>{line}</pre>")
        throttle_file.write_text(str(now_ts))
    except Exception:
        pass


def _today_auto_spent():
    """Sum $ committed (count*price) for orders placed today (UTC)."""
    log = _load_auto_log()
    today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
    return sum(e.get('dollar_committed', 0) for e in log
               if e.get('placed_at', '').startswith(today))


def _already_placed(client_order_id):
    """True if auto_trade_log.json already has an order for this fixture+side.
    Local idempotency guard: Kalshi only dedupes client_order_id against
    *resting* orders, so once the first order fills it will accept a duplicate
    (caused the 2026-05-29 Nice/STE double-bet). We never re-place on the same
    fixture+side regardless of fill status.
    """
    return any(e.get('client_order_id') == client_order_id for e in _load_auto_log())


def _resolve_ticker_for_signal(s):
    """
    Resolve the full Kalshi market ticker (event-outcome) for a signal.
    Returns (ticker_str, outcome_abr) or (None, reason_str).
    Side YES on AWAY -> outcome = away_abr; Side YES on HOME -> outcome = home_abr.
    """
    try:
        import maps
        from kalshi_live import build_event_ticker, find_event_by_abrs, get_abr
        from datetime import date as date_
    except Exception as e:
        return None, f"import: {e}"

    series = maps.league_kalshi_series((s.get('div') or '').upper())
    if not series:
        return None, f"no Kalshi series for div={s.get('div')}"

    home_abr = get_abr(s['home'])
    away_abr = get_abr(s['away'])
    if not home_abr or not away_abr:
        return None, f"missing abr home={s['home']}({home_abr}) away={s['away']}({away_abr})"

    # live_monitor uses "AWAY YES" / "HOME YES"; daily_signals uses "Away" / "Home".
    # Treat anything containing "AWAY" as away; skip NO sides (not auto-traded yet).
    side_raw = s.get('bet', 'Away')
    side_upper = side_raw.upper()
    if side_upper.startswith('NO'):
        # NO sides aren't auto-traded; expected skip, NOT an error.
        return None, '__SKIP_NO__'
    is_away = 'AWAY' in side_upper
    outcome = away_abr if is_away else home_abr

    try:
        gd = date_.fromisoformat(s['date'])
    except Exception as e:
        return None, f"bad date {s.get('date')}: {e}"

    # Try canonical event ticker first (both home/away orderings), then fall back
    # to find_event_by_abrs in case the date in the ticker differs (reschedules).
    import requests
    import time as _time
    BASE = 'https://api.elections.kalshi.com/trade-api/v2'

    # A TRANSIENT ERROR IS NOT A MISSING MARKET. This endpoint rate-limits
    # readily and the box has intermittent DNS trouble. The old code accepted
    # only 200 and let everything else fall through to "could not resolve
    # event ticker", so one 429 silently became a skipped bet that looked like
    # a team_map problem.
    #
    # 2026-09-11: D98-ARM was reported unresolvable at 00:33:33 while the
    # market had been OPEN since 09-07 under the very first ticker tried. The
    # signal was dropped for nothing. These skips are worse than random --
    # they cluster when the API is under load, and they quietly shrink the
    # xlow forward test while looking like a mapping bug nobody chases.
    def _probe(tkr):
        """(True, None) found | (False, None) absent | (False, err) unknown."""
        last = None
        for attempt in range(4):
            try:
                r = requests.get(f"{BASE}/markets/{tkr}", timeout=10)
            except Exception as e:
                last = f'{type(e).__name__}: {e}'
            else:
                if r.status_code == 200:
                    return True, None
                if r.status_code == 404:
                    return False, None      # definitively not a market
                last = f'HTTP {r.status_code}'
            _time.sleep(1.5 * (attempt + 1))
        return False, last

    indeterminate = None
    for ordering in (build_event_ticker(series, gd, home_abr, away_abr),
                     build_event_ticker(series, gd, away_abr, home_abr)):
        tkr = f"{ordering}-{outcome}"
        found, err = _probe(tkr)
        if found:
            return tkr, outcome
        if err:
            indeterminate = err

    ev = find_event_by_abrs(series, home_abr, away_abr)
    if ev:
        return f"{ev}-{outcome}", outcome
    if indeterminate:
        # Say WHAT failed. "could not resolve event ticker" sent me to
        # team_map.json, which was correct all along.
        return None, (f"kalshi lookup FAILED (NOT a missing market) for "
                      f"{series} {home_abr}-{away_abr} {s['date']}: "
                      f"{indeterminate} -- signal skipped, retry may succeed")
    return None, f"could not resolve event ticker for {series} {home_abr}-{away_abr} {s['date']}"


def pin_fair_ok(signal):
    """Shared execution-only longshot filter (see AUTO_TRADE_MIN_PIN_FAIR).

    Called by BOTH real-order paths -- this module's Kalshi placer and
    poly_us_trader's -- so a signal cannot slip money onto one venue after being
    blocked on the other. Returns True when the bet is allowed to trade real.

    A missing/unparseable pin_fair ALLOWS the order rather than blocking it: xlow
    entries always carry the field (live_monitor sets it when building the entry),
    so absence means a non-xlow caller, and silently blocking every such signal
    would be a much larger behaviour change than the one that was asked for.
    """
    raw = signal.get('pin_fair')
    try:
        pf = float(raw)
    except (TypeError, ValueError):
        print(f"  [AUTO] no pin_fair on signal ({signal.get('home')} vs "
              f"{signal.get('away')}) -> longshot filter not applied")
        return True
    if pf <= 0:
        return True
    if pf < AUTO_TRADE_MIN_PIN_FAIR:
        print(f"  [AUTO] pin_fair {pf:.3f} < {AUTO_TRADE_MIN_PIN_FAIR:.2f} "
              f"({signal.get('home')} vs {signal.get('away')} {signal.get('bet')}) "
              f"-> paper only (longshot filter)")
        return False
    return True


def place_auto_order(signal):
    """
    Auto-place a Kalshi limit-at-ask order for an eligible CLV signal.
    Returns dict with order details on success, None on skip/failure.

    Safety gates (in order):
      1. AUTO_TRADE_ENABLED master flag
      2. Kill-switch file exists -> abort
      3. League whitelist
      4. Signal has kalshi_stake / kalshi_ask
      5. Daily $ cap not exceeded
      6. Ticker resolves on Kalshi
      7. Re-fetch live ask; reject if drifted >20% from signal ask
      8. Sanity: ask < 0.95 (can't price above 95c)
      9. Idempotent client_order_id (Kalshi rejects duplicates)
      10. kalshi_trader internal caps re-enforce $1/order
    """
    if not AUTO_TRADE_ENABLED:
        return None
    if os.path.exists(AUTO_TRADE_KILL_FILE):
        print(f"  [AUTO] kill-switch engaged -> skip")
        return None
    if AUTO_TRADE_LEAGUES is not None and signal.get('div') not in AUTO_TRADE_LEAGUES:
        return None
    if signal.get('div') in AUTO_TRADE_EXCLUDE_LEAGUES:
        print(f"  [AUTO] {signal.get('div')} excluded from real orders -> paper only")
        return None
    # Strategy gate (2026-07-24): only the frozen xlow rules trade real money.
    # This function is shared by BOTH paths -- live_monitor's xlow signals (tagged
    # strategy=xlow / xlow_polyus) and this module's own baseline B365-CLV signals
    # (untagged). Clearing AUTO_TRADE_EXCLUDE_LEAGUES for xlow silently switched
    # baseline on too, so baseline started placing real orders (Santos, Chivas
    # 2026-07-25) despite being a separate, weaker signal whose own forward test
    # is negative. Flip BASELINE_AUTO_TRADE_ENABLED to re-enable deliberately.
    if signal.get('strategy') not in ('xlow', 'xlow_polyus'):
        if not BASELINE_AUTO_TRADE_ENABLED:
            print(f"  [AUTO] baseline signal ({signal.get('home')} vs {signal.get('away')}) "
                  f"-> paper only (BASELINE_AUTO_TRADE_ENABLED=False)")
            return None
    if not pin_fair_ok(signal):
        return None
    if not signal.get('kalshi_stake') or not signal.get('kalshi_ask'):
        return None

    # Local idempotency: never re-place on a fixture+side we've already ordered,
    # even across restarts/days (Kalshi's own dedupe only covers resting orders).
    bet_norm = 'away' if 'AWAY' in signal.get('bet', 'Away').upper() else 'home'
    client_order_id = f"auto-{signal.get('fixture_id')}-{bet_norm}"
    if _already_placed(client_order_id):
        print(f"  [AUTO] {client_order_id} already in log -> skip (idempotent)")
        return None

    spent = _today_auto_spent()
    if AUTO_TRADE_DAILY_CAP is not None and spent >= AUTO_TRADE_DAILY_CAP:
        print(f"  [AUTO] daily cap ${AUTO_TRADE_DAILY_CAP} reached (spent=${spent:.2f}) -> skip")
        return None

    ticker, info = _resolve_ticker_for_signal(signal)
    if not ticker:
        # Silent skip for NO sides and clearly-fake test signals (year >= 2099);
        # only email on real resolution failures.
        is_skip = (info == '__SKIP_NO__'
                   or str(signal.get('date', '')).startswith('20') and
                   str(signal.get('date', ''))[:4] >= '2099')
        if not is_skip:
            _auto_log_error(f"ticker resolve failed for {signal.get('home')} vs {signal.get('away')}: {info}")
        return None

    # Re-fetch current price (signal may be stale by minutes)
    try:
        import kalshi_trader as kt
        mk = kt.get_market(ticker).get('market', {})
        live_ask = float(mk.get('yes_ask_dollars') or 0)
        status = mk.get('status', '?')
    except Exception as e:
        _auto_log_error(f"get_market failed for {ticker}: {e}")
        return None

    if status != 'active':
        print(f"  [AUTO] {ticker} status={status} -> skip")
        return None
    if live_ask <= 0 or live_ask >= 0.95:
        print(f"  [AUTO] {ticker} live_ask={live_ask} out of range -> skip")
        return None

    # F10 FLOOR, same as the Poly-US placer (2026-08-31, user: "it should be
    # xlow f10 not the regular"). The variant is the frozen primary minus
    # entries priced under 10c; the sub-10c bucket went 0W-12L in the backtest.
    #
    # It has to live on BOTH placers or the filter depends on which venue happens
    # to carry the fixture -- a sub-10c signal would be refused on Poly-US and
    # then quietly taken on the Kalshi fallback, which is the worst of both.
    #
    # EXECUTION-ONLY, like AUTO_TRADE_MIN_PIN_FAIR: the signal still fires, still
    # lands in pending_bets, and still counts unfiltered in the frozen forward
    # test. Imported from the forward test so the two cannot drift.
    try:
        import sys as _sys, os as _os
        _sys.path.insert(0, _os.path.join(_os.path.dirname(_os.path.dirname(
            _os.path.abspath(__file__))), 'research'))
        from forward_test_unique_book import XLOW_PRICE_FLOOR as _F10
    except Exception:
        _F10 = 0.10
    try:
        from forward_test_unique_book import BAND as _BAND
    except Exception:
        _BAND = (0.05, 0.12)
    if live_ask < _F10:
        print(f"  [AUTO] {ticker} ask={live_ask:.3f} below the f10 floor "
              f"{_F10:.2f} -> paper only")
        return None
    # DRIFT, NOT A BAND RE-TEST (corrected 2026-09-07).
    #
    # The first version of this guard recomputed CLV at the live ask and
    # required it inside BAND. That is wrong: the rule FIRES on the mid and
    # ALWAYS pays the ask, and the forward test scores at the ask -- so the
    # +23%% headline is already net of that spread. Demanding the ask clear the
    # band is a different, never-measured strategy, and it refused ~75%% of
    # signals: Elche v Real Sociedad was skipped at ask 0.310 vs fair 0.324
    # (CLV +4.2%%) when the rule had fired at mid 0.3055 (+5.7%%). Normal.
    #
    # What actually needed catching is the price MOVING between signal and
    # fill -- FC Sion, mid 0.50 at signal, ask 0.99 at placement.
    #
    # The reference is the mid recorded at signal time, or fair*(1-clv) which
    # reconstructs it. NOT the stored ask: live_monitor overwrites that with
    # the live price on the Poly-US fallback path, which is how the original
    # 20%% check was silently defeated.
    _ref = None
    try:
        _m = float(signal.get('entry_k_mid') or 0)
        if 0 < _m < 1:
            _ref = _m
    except (TypeError, ValueError):
        pass
    if _ref is None:
        try:
            _f2 = float(signal.get('pin_fair') or 0)
            _c2 = float(signal.get('clv_kalshi') or 0) / 100.0
            _p2 = _f2 * (1.0 - _c2)
            if 0 < _p2 < 1:
                _ref = _p2
        except (TypeError, ValueError):
            pass
    if _ref is not None and abs(live_ask - _ref) / _ref > 0.20:
        _auto_log_error(f"price drift {ticker}: signal {_ref:.3f} -> live {live_ask:.3f}")
        print(f"  [AUTO] {ticker} drifted: signal {_ref:.3f} -> live {live_ask:.3f} -> skip")
        return None

    signal_ask = float(signal['kalshi_ask'])
    if abs(live_ask - signal_ask) / signal_ask > 0.20:
        _auto_log_error(f"price drift {ticker}: signal={signal_ask} live={live_ask}")
        return None

    # Sizing: cap by AUTO_TRADE_PER_BET_CAP and remaining daily budget (if any)
    caps = [float(signal['kalshi_stake']), AUTO_TRADE_PER_BET_CAP]
    if AUTO_TRADE_DAILY_CAP is not None:
        caps.append(AUTO_TRADE_DAILY_CAP - spent)
    budget = min(caps)
    price_cents = int(round(live_ask * 100))
    if price_cents < 1:
        return None
    # ROUND UP, NOT DOWN (2026-09-01, user: "put it at least one share that
    # will make it over $1").
    #
    # Kalshi sells whole contracts, so floor division spent whatever was left
    # under one contract's price -- and how much that wasted depended entirely
    # on the price. At 0.26 a $1 budget bought 3 for $0.78; at 0.55 it bought
    # ONE, for $0.55. So a rule meant to stake $1 a bet was really staking
    # $0.55 to $1.10 depending on where the ask happened to land, which makes
    # the bets non-comparable and quietly under-stakes the expensive ones.
    #
    # Ceiling makes every bet at least the budget. Overshoot is bounded by one
    # contract, so worst case is budget + price; kalshi_trader's
    # MAX_DOLLARS_PER_ORDER went 1.00 -> 2.00 to cover it and is still the hard
    # backstop.
    _px = price_cents / 100.0
    count = int(math.ceil(round(budget / _px, 9)))
    if count < 1:
        print(f"  [AUTO] budget=${budget:.2f} at {price_cents}c -> no size")
        return None
    # Mirror kalshi_trader's hard contract cap here for clarity (it re-checks).
    count = min(count, kt.MAX_CONTRACTS_PER_ORDER)
    dollar_committed = count * price_cents / 100.0

    # $50 ACCOUNT FLOOR (2026-09-29, user). Checked last, right before the
    # order goes out, so skipped signals cost no extra API call. A breach
    # writes the kill file itself, so every later order stops at the
    # kill-switch check above until a person clears it.
    if kt.kalshi_floor_breached('xlow place_auto_order'):
        return None

    try:
        resp = kt.place_limit_order(ticker, 'yes', count, price_cents,
                                     client_order_id=client_order_id,
                                     subaccount=XLOW_SUBACCOUNT)
        order = resp.get('order', resp)
    except Exception as e:
        _auto_log_error(f"place_limit_order failed {ticker} {count}@{price_cents}: {e}")
        return None

    record = {
        'placed_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
        'order_id': order.get('order_id'),
        'client_order_id': client_order_id,
        'ticker': ticker,
        'side': 'yes',
        'count': count,
        'price_cents': price_cents,
        'dollar_committed': dollar_committed,
        'fixture_id': signal.get('fixture_id'),
        'home': signal.get('home'),
        'away': signal.get('away'),
        'div': signal.get('div'),
        'signal_ask': signal_ask,
        'live_ask': live_ask,
    }
    log = _load_auto_log()
    log.append(record)
    _save_auto_log(log)
    print(f"  [AUTO] PLACED {ticker} {count}@{price_cents}c (${dollar_committed:.2f}) id={order.get('order_id')}")

    # No separate confirmation email (2026-07-24, user instruction: combine
    # xlow + auto-trade into one email) -- the batched XLOW ALERT already
    # shows this fill in its "Real Trade" column via _log_pending_bets'
    # trade_status return value. This function's return `record` still feeds
    # that column; only the standalone email is gone.
    return record


def _settled_keys():
    """(date|home|away) for every bet already RESOLVED into bet_history.

    Dedupe used to consult pending_bets.json alone, which only protects a bet
    while it is still open. An early-kickoff match that SETTLED between two runs
    of this job left pending, and the next run happily logged the same signal a
    second time -- booking its P&L twice. Found 2026-08-08 on Independiente
    Rivadavia v Estudiantes de Rio Cuarto (Liga Profesional, -1.67 counted
    twice). Rare today because most fixtures resolve after the day's last run,
    but Liga Profesional, Liga MX and the Asian leagues all kick off inside the
    working day, so it gets more likely as coverage grows.

    Keyed WITH the date so a genuine rematch on another day still logs.
    """
    try:
        raw = json.loads(Path(HISTORY_FILE).read_text(encoding='utf-8'))
    except Exception:
        return set()
    rows = raw if isinstance(raw, list) else raw.get('bets', raw)
    return {f"{str(b.get('date'))[:10]}|{b.get('home')}|{b.get('away')}"
            for b in rows}


def add_to_pending(signals):
    if not B365_BETTING_ENABLED:
        # Retired 2026-08-09 -- see B365_BETTING_ENABLED. This is the ONLY entry
        # point for baseline bets, so returning here stops them at source while
        # leaving signal generation, logging and the b365 odds themselves intact.
        if signals:
            print(f'  [b365 RETIRED] {len(signals)} baseline signals NOT staked')
        return
    pending = load_pending()
    existing_keys = {f"{b['home']}|{b['away']}" for b in pending}
    settled = _settled_keys()
    for s in signals:
        key = f"{s['home']}|{s['away']}"
        if f"{str(s.get('date'))[:10]}|{s['home']}|{s['away']}" in settled:
            print(f"  [DEDUPE] {s['home']} vs {s['away']} already settled today "
                  f"-- not re-logging")
            continue
        if key not in existing_keys:
            entry = {
                'date': s['date'], 'home': s['home'], 'away': s['away'],
                'league': s['league'], 'div': s['div'], 'bet': s.get('bet', 'Away'),
                'fixture_id': s.get('fixture_id'),
                'b365': s['b365'], 'stake': s['stake'],
                'kelly_f': s['kelly_f'], 'edge_pct': s['edge_pct'],
            }
            if s.get('kalshi_stake'):
                entry['kalshi_ask']   = s['kalshi_ask']
                entry['kalshi_stake'] = s['kalshi_stake']
            if s.get('poly_stake'):
                entry['poly_ask']   = s['poly_ask']
                entry['poly_stake'] = s['poly_stake']
            pending.append(entry)
            existing_keys.add(key)
            # Auto-trade hook (Phase 3) - gated by AUTO_TRADE_ENABLED + kill-switch
            if AUTO_TRADE_ENABLED:
                try:
                    rec = place_auto_order(s)
                    if rec:
                        for k in ('order_id', 'placed_at', 'count', 'price_cents'):
                            entry[f'auto_{k}'] = rec[k]
                            s[f'auto_{k}']     = rec[k]  # so build_html can show it
                except Exception as e:
                    _auto_log_error(f"add_to_pending hook crashed for {s.get('home')} vs {s.get('away')}: {e}")
    save_pending(pending)

def log_signals(signals):
    """Append signals to CSV log for tracking."""
    import csv
    fields = ['date','time','league','div','home','away','b365','fair','edge_pct',
              'kelly_f','kelly_pct','stake','p_win','pin_source',
              'kalshi_mid','kalshi_ask','kalshi_edge_pct','kalshi_kelly_f','kalshi_stake',
              'poly_mid','poly_ask','poly_edge_pct','poly_kelly_f','poly_stake']
    write_header = not os.path.exists(SIGNAL_LOG)
    with open(SIGNAL_LOG, 'a', newline='') as f:
        w = csv.DictWriter(f, fieldnames=fields + ['logged_at'])
        if write_header:
            w.writeheader()
        for s in signals:
            row = {k: s.get(k, '') for k in fields}
            row['logged_at'] = datetime.now().strftime('%Y-%m-%d %H:%M')
            w.writerow(row)

# ── Scan schedule ────────────────────────────────────────────────────────
# Runs daily — pulls today's odds from API-Football (closer to closing line).
# Task Scheduler fires at 9 AM ET every day.

# ── Main ─────────────────────────────────────────────────────────────────
def main():
    today = datetime.now().strftime('%Y-%m-%d')
    day_name = datetime.now().strftime('%A')
    print(f"\n=== Away CLV Signal Scanner - {today} ({day_name}) ===")

    # Step 1: Resolve pending bets (runs every day)
    print("Checking pending bets...")
    resolved         = resolve_pending_bets()
    BANKROLL         = load_bankroll()
    KALSHI_BANKROLL  = load_kalshi_bankroll()

    # Also surface any bets resolved by the midnight job (resolved_at = today or yesterday)
    recent_cutoff = (datetime.now() - timedelta(hours=24)).strftime('%Y-%m-%d')
    resolved_ids  = {f"{r['home']}|{r['away']}" for r in resolved}
    for h in load_history():
        if h.get('resolved_at', '') >= recent_cutoff:
            key = f"{h['home']}|{h['away']}"
            if key not in resolved_ids:
                resolved.append(h)
                resolved_ids.add(key)
    pending          = load_pending()
    committed        = sum(b.get('stake', 0) for b in pending)
    available        = max(BANKROLL - committed, 0)
    print(f"  Resolved: {len(resolved)}  |  Bankroll: ${BANKROLL:,.2f}  |  Kalshi Bankroll: ${KALSHI_BANKROLL:,.2f}")
    if pending:
        print(f"  Pending: {len(pending)} bets (${committed:,.2f} committed)  |  Available: ${available:,.2f}")

    # Step 2: Pull today's fixtures + odds from API-Football
    print("Fetching today's odds from API-Football...")
    signals, has_odds = scan_fixtures(BANKROLL, KALSHI_BANKROLL)
    print(f"  Signals found: {len(signals)}")
    for s in signals:
        print(f"    {s['league']}: {s['home']} vs {s['away']}  B365A={s['b365']}  "
              f"fair={s['fair']}  edge={s['edge_pct']}%  stake=${s['stake']:.2f}")

    # Include today's pending B365 bets as reminders (Kalshi-only bets skipped — no B365 data)
    pending_today = [b for b in pending if b.get('date') == today and b.get('b365')]
    for b in pending_today:
        if not any(s['home'] == b['home'] and s['away'] == b['away'] for s in signals):
            p = 1 / b['b365']
            signals.append({
                **b,
                'time': b.get('time', ''),
                'fair': round(1/p, 2),
                'p_win': p,
                'kelly_pct': round(b.get('kelly_f', 0) * 100, 1),
                '_reminder': True,
            })

    new_signals, today_sent, sent = filter_new(signals, today)
    print(f"  New (not yet sent): {len(new_signals)}")

    # Phase 3: run add_to_pending BEFORE build_html so the auto-trade hook fires
    # and stamps order_id/placed_at onto each signal dict for display in the email.
    # add_to_pending is idempotent on home|away so re-calling later (line ~1295)
    # is a no-op for pending bookkeeping.
    fresh_pre = [s for s in new_signals if not s.get('_reminder')]
    if fresh_pre:
        add_to_pending(fresh_pre)

    if not new_signals:
        reason = "All signals already sent today." if signals else "No Away bets met the 5% CLV threshold."
        subject = f"NO NEW SIGNALS - ${BANKROLL:,.2f}"
        html = build_html([], today, BANKROLL, kalshi_bankroll=KALSHI_BANKROLL, resolved=resolved or None, reason=reason)
    else:
        n = len(new_signals)
        # `or 0` not a .get default: a retired-b365 signal carries the keys with
        # value None, so the default never fires and the sum raises TypeError.
        total_b365   = sum(s.get('stake') or 0 for s in new_signals)
        total_kalshi = sum(s.get('kalshi_stake') or 0 for s in new_signals)
        n_away = sum(1 for s in new_signals if s.get('bet') == 'Away')
        n_home = sum(1 for s in new_signals if s.get('bet') == 'Home')
        parts = []
        if n_away: parts.append(f"{n_away} Away")
        if n_home: parts.append(f"{n_home} Home")
        stake_str = f"B365 ${total_b365:,.2f} + Kalshi ${total_kalshi:,.2f}" if total_b365 and total_kalshi else \
                    f"Kalshi ${total_kalshi:,.2f}" if total_kalshi else f"${total_b365:,.2f}"
        subject = f"{' + '.join(parts)} CLV BET{'S' if n!=1 else ''} - {stake_str}"
        html = build_html(new_signals, today, BANKROLL, kalshi_bankroll=KALSHI_BANKROLL, resolved=resolved or None)

    # Step 3: Add resolved context to subject
    if resolved:
        wins = sum(1 for r in resolved if r['won'])
        subject = f"[{wins}W/{len(resolved)-wins}L] {subject}"

    # Step 4: Email suppressed by user request (2026-05-23) — live_monitor.py CLV ALERT
    # and resolve_bets.py NIGHTLY RESULTS already cover this surface. Bookkeeping
    # (mark_sent / add_to_pending / log_signals) still runs as if email had been sent.
    print(f"  Subject (suppressed): {subject}")
    sent_ok = True

    # Step 5: Log and track (only mark sent if email delivered; skip reminders)
    fresh = [s for s in new_signals if not s.get('_reminder')]
    if fresh and sent_ok:
        mark_sent(fresh, today, today_sent, sent)
        add_to_pending(fresh)
        log_signals(fresh)
        print(f"  {len(new_signals)} signals logged and added to pending")

    if signals:
        print(f"\n  SUMMARY: {len(signals)} signals, total stake ${sum(s.get('stake', 0) for s in signals):,.2f}")

    print("Done.")

if __name__ == '__main__':
    main()
