"""
Soccer betting dashboard -- generates a self-contained HTML snapshot of
everything worth watching, from local state + live venue APIs.

Answers the questions that otherwise get asked ad-hoc:
  - what are today's/tomorrow's xlow signals, and did they actually trade?
  - what real positions are open, on which venue, at what cost/value?
  - how is the frozen xlow forward test tracking? (its n>=100 checkpoint was
    passed long ago -- the open question is the review it was meant to trigger)
  - which leagues are actually working end-to-end, and which are blocked?

Usage:  python main\\dashboard.py            -> writes E:\\soccer\\dashboard.html
        python main\\dashboard.py --quiet    -> no stdout chatter (for scheduling)

Read-only. Places no orders, mutates no state.
"""
import csv
import json
import sys
import os
from datetime import date, timedelta, datetime, timezone
from pathlib import Path
from collections import defaultdict

sys.path.insert(0, r'E:\soccer\main')
sys.path.insert(0, r'E:\soccer\research')

DATA = Path(r'E:\soccer\data')
LIVE = Path(r'E:\soccer\live_data')
OUT = Path(r'E:\soccer\dashboard.html')

QUIET = '--quiet' in sys.argv
BAND_LO, BAND_HI = 0.05, 0.12
# CHECKPOINT_N removed 2026-08-16: the n>=100 checkpoint was passed at ~3x and
# every place that rendered it read "328/100", a ratio that stopped carrying
# information the moment it exceeded 1.
# NOTIONAL stake for every PAPER figure on this page -- the xlow paper KPI
# tiles. These rules stake nothing real, so there is no true bet size
# to report, but "+8.7% ROI" hides whether it came off a 0.22 shot or a 0.46 one
# and money makes that legible.
#
# forward_test_unique_book.stat() scores ONE UNIT per bet: profit (1-px)/px on a
# win, -1 on a loss. Multiplying that unit by a flat notional keeps every paper
# figure arithmetically identical to the ROI reported for the same bets. A
# per-bet size that varied would quietly stop agreeing with it.
#
# ONE constant: kept shared so the all-time tile and the 24h tile stay
# arithmetically comparable rather than merely looking it.
PAPER_NOTIONAL = 10.0
# Opening balance for the two PAPER books, so the tiles can show a running
# bankroll and not just cumulative P&L. Neither rule has a real ledger to read
# (xlow's real ledger is the signals panel), so this is a stated convention,
# not a measured
# figure: 100 units of PAPER_NOTIONAL. It is arbitrary in the same way the
# notional is, and for the same reason -- it makes a percentage legible as money.
#
# Bankroll = START + net. That is FLAT staking, which is what stat() scores and
# what the forward test is run on (project_xlow_flat_vs_kelly: flat ~1% units
# beat 0.25-Kelly on the 286-bet stream). It is NOT compounding -- the stake
# stays PAPER_NOTIONAL regardless of the balance -- so this tile must never be
# read as a growth curve.
PAPER_BANKROLL_START = 1000.0
# Row cap for the maker's per-book drill-down. It grows by ~10 books a day and
# had reached 252, which is a lot of scrolling on a phone for a table nobody
# reads past the recent end. The aggregates above it are always computed from
# every book, so this only trims what is DISPLAYED.
MM_BOOKS_MAX = 50


def log(*a):
    if not QUIET:
        print(*a)


def _jload(p, default):
    try:
        return json.loads(Path(p).read_text(encoding='utf-8'))
    except Exception:
        return default


# ── live venue state (best-effort; dashboard still renders if a venue is down)

def subaccounts():
    """Cash / positions / resting orders per Kalshi subaccount.

    THE APP CANNOT SHOW THIS. Kalshi's Portfolio screen displays the PRIMARY
    subaccount only -- the arrow beside it toggles deposit/withdraw, it is not
    an account switcher (checked with the user 2026-08-24). So a $5 transfer to
    subaccount 1 read as a -$4.46 portfolio loss in the app, and anything that
    ever runs on a non-primary subaccount would be invisible on the phone.
    This panel is the only place it can be seen.

    Reads are subaccount-scoped by the API, and the DEFAULT for positions and
    orders is subaccount 0 -- byte-identical to passing subaccount=0, which is
    why the rest of this file needed no changes when subaccount 1 was created.

    Returns [] when only the primary exists, so the section renders nothing on
    a single-subaccount account rather than adding an empty box.
    """
    try:
        import kalshi_trader as kt
        bals = kt._get('/portfolio/subaccounts/balances').get(
            'subaccount_balances', []) or []
    except Exception as e:
        return [{'n': None, 'error': str(e)[:200]}]
    if len(bals) <= 1:
        return []

    # local float parser: the module's _f helpers are nested inside other
    # functions, so nothing named _f exists at this scope
    def _num(x):
        try:
            return float(x)
        except (TypeError, ValueError):
            return 0.0

    out = []
    for b in sorted(bals, key=lambda x: x.get('subaccount_number', 0)):
        n = b.get('subaccount_number', 0)
        row = {'n': n, 'cash': _num(b.get('balance')),
               'long': 0, 'short': 0, 'resting': 0, 'error': None}
        try:
            mp = (kt._get('/portfolio/positions', params={'subaccount': n})
                  .get('market_positions') or [])
            for p in mp:
                q = _num(p.get('position_fp'))
                if q > 0:
                    row['long'] += 1
                elif q < 0:
                    row['short'] += 1
            row['resting'] = len(
                kt._get('/portfolio/orders',
                        params={'status': 'resting', 'limit': 200,
                                'subaccount': n}).get('orders') or [])
        except Exception as e:
            row['error'] = str(e)[:120]
        out.append(row)
    return out


def venue_state():
    out = {'kalshi': {'balance': None, 'positions': [], 'error': None},
           'polyus': {'balance': None, 'positions': [], 'error': None}}
    try:
        import kalshi_trader as kt
        # TOTAL, NOT PRIMARY CASH (2026-09-01, user: "Kalshi cash is misleading
        # without the subaccount. Have the total with pending and subaccounts").
        #
        # The tile used to read /portfolio/balance, which is the PRIMARY
        # subaccount's cash and nothing else. After $50 moved to subaccount 1
        # for xlow it showed $31.61 against a real $101.65 -- it looked like
        # two thirds of the account had evaporated. Two things were missing:
        # the other subaccount, and the money already committed to open
        # positions, which is not spent, just not liquid.
        #
        # /portfolio/balance takes a subaccount param (verified), so each is
        # read separately rather than inferring one from the other.
        _cash, _pending = {}, {}
        for _sub in (0, 1):
            try:
                _b = kt._get('/portfolio/balance', params={'subaccount': _sub}) or {}
                _cash[_sub] = float(_b.get('balance', 0) or 0) / 100.0
                _pending[_sub] = float(_b.get('portfolio_value', 0) or 0) / 100.0
            except Exception:
                continue          # a subaccount that will not read is omitted, not zeroed
        out['kalshi']['cash_by_sub'] = _cash
        out['kalshi']['cash'] = sum(_cash.values())
        out['kalshi']['pending'] = sum(_pending.values())
        out['kalshi']['balance'] = out['kalshi']['cash'] + out['kalshi']['pending']
        # UNION ACROSS SUBACCOUNTS. get_positions() defaults to subaccount 0,
        # and since 2026-09-01 xlow trades in subaccount 1 -- reading the
        # default alone would silently empty the xlow half of this panel and
        # make it look like nothing had been bet. Merged rather than shown
        # per-subaccount because the panel already groups by strategy, which
        # is the split that matters; the subaccount is just where it lives.
        _mp = list(kt.get_positions().get('market_positions', []) or [])
        _seen0 = {m.get('ticker') for m in _mp}
        for _sub in (1,):
            try:
                for m in (kt._get('/portfolio/positions',
                                  params={'subaccount': _sub}).get(
                                      'market_positions') or []):
                    if m.get('ticker') not in _seen0:
                        _mp.append(m)
            except Exception:
                pass          # a subaccount that cannot be read must not blank the panel
        for m in _mp:
            if float(m.get('position_fp') or 0) == 0:
                continue
            # `game` groups the three legs of one match together in the
            # positions table. A Kalshi ticker is EVENT-LEG, so everything
            # before the last dash is the match.
            tkr = m['ticker']
            out['kalshi']['positions'].append({
                'ref': tkr,
                'game': tkr.rsplit('-', 1)[0],
                'leg': tkr.rsplit('-', 1)[-1],
                'qty': m.get('position_fp'),
                'cost': float(m.get('market_exposure_dollars') or 0),
                'value': None,
                'fees': float(m.get('fees_paid_dollars') or 0),
            })
    except Exception as e:
        out['kalshi']['error'] = str(e)[:200]
    try:
        import poly_us_auth as pa
        b = pa.get_account_balances()['balances'][0]
        # TOTAL, MATCHING THE KALSHI TILE (2026-09-04, user: "make poly us cash
        # like Kalshi cash"). currentBalance is spendable cash only, so a tile
        # showing it alone drops whatever is committed to open positions and
        # reads as a loss the moment a bet is placed -- the same thing the
        # Kalshi tile did before 2026-09-01.
        #
        # cashValue per position is Poly-US's own mark, the direct analogue of
        # Kalshi's portfolio_value, so the two tiles now mean the same thing.
        # No subaccount line here: Poly-US has no subaccounts.
        out['polyus']['cash'] = float(b['currentBalance'])
        _pus_pending = 0.0
        for slug, p in (pa.get_positions().get('positions') or {}).items():
            md = p.get('marketMetadata', {}) or {}
            team = (md.get('team') or {}).get('name', '')
            title = md.get('title', slug)
            out['polyus']['positions'].append({
                'ref': f"{title} — {team}".strip(' —'),
                'game': title,          # the match; `team` is the leg within it
                'leg': team or slug,
                'qty': p.get('netPositionDecimal'),
                'cost': float(p['cost']['value']),
                'value': float(p['cashValue']['value']),
                'fees': float((p.get('fees') or {}).get('value') or 0),
            })
            _pus_pending += float(p['cashValue']['value'])
        out['polyus']['pending'] = _pus_pending
        out['polyus']['balance'] = out['polyus']['cash'] + _pus_pending
    except Exception as e:
        out['polyus']['error'] = str(e)[:200]
    return out


# ── signals: pending (unsettled) + settled, with real-trade status

def arm_labels():
    """event -> pricing arm, from the maker's append-only assignment log.

    Only arms the maker still recognises are returned: the retired vol/ko
    SELECTION experiment wrote into the same log, and a stale label rendered as
    a current one would be worse than no label.
    """
    path = DATA.parent / 'research' / 'mm_arm_log.csv'
    live = {'budget', 'latch3', 'tieload'}
    out = {}
    try:
        with open(path, newline='', encoding='utf-8') as fh:
            for r in csv.DictReader(fh):
                if r.get('event') and r.get('arm') in live:
                    out[r['event']] = r['arm']      # last assignment wins
    except (OSError, ValueError):
        pass
    return out


def signals_for(dates, ftr_map=None):
    pend = _jload(DATA / 'pending_bets.json', [])
    hist = _jload(DATA / 'bet_history.json', [])
    rows = []
    for b in pend:
        if b.get('date') not in dates:
            continue
        # PROVISIONAL SETTLEMENT FROM THE MATCH RECORD. The ledger stays the
        # source of truth -- this only fills the Result column while the bet
        # waits for daily_signals to move it into bet_history at 04:00. Marked
        # with a dot so provisional is never mistaken for reconciled.
        rows.append(_sig_row(b, settled=_provisional(b, ftr_map)))
    for b in hist:
        if b.get('date') not in dates:
            continue
        pnl = b.get('kalshi_pnl')
        if pnl is None:
            pnl = b.get('poly_pnl')
        if pnl is None:
            pnl = b.get('pnl')
        rows.append(_sig_row(b, settled={'ftr': b.get('ftr'), 'pnl': pnl}))
    rows.sort(key=lambda r: (r['date'], r['div'], r['match']))
    return rows


def scope_signals(rows, ft):
    """Signals in the live scope, plus how many were dropped.

    The signals panel is an OPERATIONAL view -- what the rule is doing now --
    so leagues outside the scope do not belong in it (user, 2026-08-23). They
    are not lost: they keep firing into pending_bets and the forward test, and
    the by-league table still carries them (the "outside scope, tracking"
    summary row was removed from the page 2026-09-12; the series survives in
    forward_test_unique_book_log.csv).

    Returns (kept, hidden_count) rather than filtering silently -- a table that
    quietly drops rows is how you end up trusting a number that is missing its
    denominator. The count is rendered next to the heading.

    Falls back to NO filtering if the scope is unavailable (forward_test error),
    because showing everything is a much smaller failure than showing nothing.
    """
    scope = set(ft.get('bt_leagues') or [])
    if not scope:
        return rows, 0
    # ANYTHING THAT TRADES REAL MONEY BELONGS IN AN OPERATIONAL VIEW (2026-09-06).
    #
    # bt_leagues is PRIMARY_LEAGUES -- the frozen MEASUREMENT scope. Since
    # 2026-09-05 that is no longer the same set as what actually trades: CH1
    # bets real money through the GAP path (Poly-US as the confirming book)
    # while being deliberately kept out of the frozen primary, so the headline
    # stays comparable to its own history.
    #
    # The result was that two CH1 bets placed today were invisible on the panel
    # whose docstring says it shows "what the rule is doing now". A panel that
    # hides live positions is worse than one that shows an extra league.
    #
    # Union rather than replace: the measurement scope still defines the
    # baseline, and this only ADDS leagues we have money on. Fails open to the
    # measurement scope if daily_signals cannot be read.
    try:
        from daily_signals import AUTO_TRADE_LEAGUES, AUTO_TRADE_EXCLUDE_LEAGUES
        scope |= (set(AUTO_TRADE_LEAGUES) - set(AUTO_TRADE_EXCLUDE_LEAGUES))
    except Exception:
        pass
    # PRICE FLOOR ON THE OPERATIONAL VIEW (2026-09-12, user: "0.1").
    #
    # Dortmund vs Paderborn fired at ask 0.09 on a pin_fair of 0.0879 and sat
    # in this table looking like a bet. It was never one: pin_fair_ok() blocks
    # real money under AUTO_TRADE_MIN_PIN_FAIR (0.15), so a sub-floor signal is
    # paper by construction. A panel that says "what the rule is doing now"
    # should not show a 9c longshot nobody could have backed.
    #
    # DISPLAY ONLY. XLOW_PRICE_FLOOR is NOT applied to the forward test here or
    # anywhere in research/ -- the frozen rule counts every signal unfiltered,
    # which is the whole point of an execution-only floor, and filtering a
    # frozen measurement after seeing losses is the post-hoc trap that already
    # cost the BSA/CL1 removals their credibility. The f10 row on this page is
    # the sanctioned way to see the floored series.
    try:
        from forward_test_unique_book import XLOW_PRICE_FLOOR as _floor
    except Exception:
        _floor = 0.10
    def _in_view(r):
        if r['div'] not in scope:
            return False
        a = _row_ask(r)
        # An unknown price fails OPEN, same as an unavailable scope above:
        # hiding a row because we could not price it is the worse error.
        return a is None or a >= _floor
    kept = [r for r in rows if _in_view(r)]
    return kept, len(rows) - len(kept)


def _row_ask(r):
    """The price a signal fired at, on the venue it fired on.

    Mirrors the Ask column's own resolution in the signals table exactly, so
    the filter above can never disagree with the number the page prints.
    """
    a = r.get('ask_k') if r.get('cheap') == 'Kalshi' else r.get('ask_p')
    if a is None:
        a = r.get('ask_k') if r.get('ask_k') is not None else r.get('ask_p')
    return a


def _provisional(b, ftr_map):
    """A pending bet's result read off the match record, or None.

    Same shape as the settled path, plus provisional=True. `pnl` carries only a
    SIGN: the table scores at PAPER_NOTIONAL off the ask exactly as it does for
    reconciled bets, so the two paths cannot disagree about the money.
    """
    if not ftr_map:
        return None
    gd, lg = b.get('date'), b.get('div')
    hm, aw = b.get('home'), b.get('away')
    ftr = ftr_map.get((gd, lg, hm, aw)) or ftr_map.get((gd, lg, hm))
    if not ftr:
        return None
    bet = (b.get('bet') or '').upper()
    if 'HOME' in bet:
        won = ftr == 'H'
    elif 'AWAY' in bet:
        won = ftr == 'A'
    else:
        return None
    return {'ftr': ftr, 'pnl': (1.0 if won else -1.0), 'provisional': True}


def _signal_px(fair, clv_pct, recorded_mid, stored_ask):
    """The price the SIGNAL fired at, for the paper/research view.

    The rule evaluates CLV on the MID. `*_ask` is the quoted ask at that
    instant, and on a normal book it sits a cent away. On an empty book it does
    not: FC Sion v FC Thun had a Poly-US mid near 0.50 (giving clv_polyus
    +7.8%) and an ask of 0.99, so the row rendered "0.99 (P) +7.8%" -- a price
    and an edge that describe different prices, and a paper result scored at
    0.99 instead of at the price the rule actually saw.

    Order of preference:
      1. the mid recorded at signal time (entry_k_mid / entry_p_mid)
      2. the price implied by fair and the stored CLV -- fair*(1-clv), which is
         the mid by definition, reconstructed when the mid was not written
      3. the stored ask, unchanged

    DISPLAY ONLY. The ledger is untouched and the forward test never reads it.
    """
    try:
        m = float(recorded_mid)
        if 0 < m < 1:
            return round(m, 4)
    except (TypeError, ValueError):
        pass
    try:
        f = float(fair); c = float(clv_pct) / 100.0
        px = f * (1.0 - c)
        if 0 < px < 1:
            return round(px, 4)
    except (TypeError, ValueError):
        pass
    try:
        a = float(stored_ask)
        return a if 0 < a < 1 else None
    except (TypeError, ValueError):
        return None


def _sig_row(b, settled):
    venue = []
    if b.get('auto_order_id'):
        venue.append('Kalshi')
    if b.get('auto_polyus_order_id'):
        venue.append('Poly-US')
    cheap = None
    if b.get('kalshi_stake') and not b.get('poly_stake'):
        cheap = 'Kalshi'
    elif b.get('poly_stake'):
        cheap = 'Poly'
    def _f(x):
        try:
            return float(x)
        except (TypeError, ValueError):
            return None
    return {
        'date': b.get('date', ''),
        'div': b.get('div', '?'),
        'match': f"{b.get('home','?')} vs {b.get('away','?')}",
        # kept separate as well as joined, so the kickoff lookup can key on them
        'home': b.get('home'),
        'away': b.get('away'),
        'bet': b.get('bet', '?'),
        'strategy': b.get('strategy') or 'baseline',
        'cheap': cheap,
        'clv_k': b.get('clv_kalshi'),
        'clv_p': b.get('clv_poly'),
        # the quoted ASK on each venue at signal time -- what the bet would
        # actually cost. CLV alone hides this: two signals at the same +6% CLV
        # are very different bets at 0.14 and 0.47, and the price is what drives
        # the variance that makes this rule hard to measure.
        # THE PRICE THE SIGNAL FIRED AT, not a quote that contradicts its own
        # CLV -- see _signal_px. Keeps price and edge describing the same
        # moment, which is what a paper tracker is for.
        'ask_k': _signal_px(b.get('pin_fair'), b.get('clv_kalshi'),
                            b.get('entry_k_mid'), b.get('kalshi_ask')),
        'ask_p': _signal_px(b.get('pin_fair'), b.get('clv_poly'),
                            b.get('entry_p_mid'), b.get('poly_ask')),
        'fair': _f(b.get('pin_fair')),
        'traded': venue,
        'settled': settled,
    }


# ── forward test (frozen xlow rule)

def forward_test():
    try:
        import forward_test_unique_book as F
        # Read ONCE including unplayed fixtures, then split. `prepped` keeps its
        # old meaning exactly -- settled fixtures only -- so every scoring path
        # below is untouched, while `allfx` still holds tonight's unplayed games
        # for the last-24h panel. Loading twice would re-read ~1,800 fixtures
        # off disk for the same answer.
        allfx = F.load_fixtures(require_result=False)
        prepped = [fx for fx in allfx if fx['ftr'] is not None]
        oos = [fx for fx in prepped if fx['gd'] > F.FREEZE_DATE]
        # PRIMARY IS SCOPED TO THE BACKTEST'S LEAGUES (2026-08-23, user
        # decision). That set was fixed before the forward test began; the other
        # leagues were added deliberately to keep collecting while most of the
        # backtest set sat out of season. Pooling the two makes the headline
        # answer a different question than the one the rule was frozen on.
        # F.BACKTEST_LEAGUES holds the scope and the reasoning.
        xlow_bt = F.classify_xlow_bt(oos)
        primary = F.stat(xlow_bt)
        # THE UNSCOPED SUMMARY ROWS ARE NOT DISPLAYED (2026-09-12, user).
        # "xlow (all leagues, pooled)" and "xlow (outside scope, tracking)"
        # used to sit directly under the primary row, which invited reading a
        # pooled number that answers a DIFFERENT question than the frozen rule
        # -- and the out-of-scope slice runs deeply negative (-16.5% on n=401),
        # so side by side it reads as the rule failing when it is not in the
        # rule at all.
        #
        # NOTHING STOPS BEING MEASURED. Both series are still computed and
        # written every day by research/forward_test_unique_book.py into
        # forward_test_unique_book_log.csv (columns xlow_* and xlow_newlg_*),
        # and the out-of-scope leagues still appear individually in the
        # by-league table below and in the last-24h panel. This is a display
        # change only; do not "restore" it by re-adding the rows here.
        rows = {'xlow (PRIMARY · scoped)': primary,
                'xlow_f10': F.stat([b for b in xlow_bt
                                    if b['px'] >= F.XLOW_PRICE_FLOOR]),
                't60': F.stat(F.classify_t60(oos))}
        uniq, overlap = F.classify(oos)
        rows['unique'] = F.stat(uniq)
        rows['overlap'] = F.stat(overlap)
        # fire_xlow_primary, not fire_xlow, so USL/PL1 appear here at all --
        # they confirm against Poly-US and were missing from this table entirely
        # while sitting inside the headline. Sorted IN-SCOPE FIRST: this table
        # mixes leagues that trade with leagues that only track, and sorting
        # purely by n put CN1/BSA/KO1 at the top of what looks like the rule's
        # league breakdown.
        by_lg = defaultdict(list)
        for fx in oos:
            b = F.fire_xlow_primary(fx)
            if b:
                by_lg[fx['league']].append(b)
        leagues = {lg: F.stat(bs) for lg, bs in
                   sorted(by_lg.items(),
                          key=lambda kv: (kv[0] not in F.PRIMARY_LEAGUES,
                                          -len(kv[1])))}
        # LEAGUE z-GATE (shadow). Three separate things, kept separate on
        # purpose: what the gate would do NEXT (state), what it WOULD have done
        # over the history it was found on (discovery -- flattering by
        # construction), and what it has actually done since its own freeze
        # (evidence, n=0 until ZGATE_FREEZE passes). Collapsing the last two
        # into one number is the BSA/CL1 error, one level deeper.
        try:
            _zkept, _zdrop = F.zgate_walk(xlow_bt)
            zgate = {
                'state':      F.zgate_state(xlow_bt),
                'disc_on':    F.stat(_zkept),
                'disc_off':   primary,
                'disc_drop':  F.stat(_zdrop),
                'post_on':    F.stat(F.classify_xlow_zgate(prepped)),
                'post_off':   F.stat(F.classify_xlow_postz(prepped)),
                'freeze':     F.ZGATE_FREEZE,
                'min_n':      F.ZGATE_MIN_N,
                'floor':      F.ZGATE_FLOOR,
            }
        except Exception:
            zgate = None          # a broken shadow must never blank the page
        # GAP leagues (USL/PL1): no Poly.com at all, so fire_xlow cannot fire and
        # they were invisible here while live_monitor traded them via Poly-US.
        #
        # 2026-08-23 they were folded INTO the primary via the Poly-US
        # substitution; 2026-08-27 the user took PL1 and USL back out of the
        # scope, so this is once again a SEPARATE row of bets that are NOT in
        # the headline. The Poly-US confirm is a different book to the one the
        # n=286 backtest validated against, which is why it lives on its own.
        # Instrumentation, not evidence.
        gap = defaultdict(list)
        for fx in oos:
            b = F.fire_xlow_polyus(fx)
            if b:
                gap[fx['league']].append(b)
        gap_leagues = {lg: F.stat(bs) for lg, bs in
                       sorted(gap.items(), key=lambda kv: -len(kv[1]))}
        gap_all = F.stat([b for bs in gap.values() for b in bs])
        if gap_all:
            # Shown in the rules table too (and thus the daily email) -- the
            # by-league table alone buries it. Labelled as a subset so it is not
            # read as extra bets on top of the primary; headline KPIs read
            # rows['xlow (PRIMARY · scoped)'] by key.
            rows['xlow_polyus (USL/PL1/CH1)'] = gap_all
        # XHIGH IS NOT COMPUTED HERE ANY MORE (2026-09-01, user: keep tracking
        # it, but it does not need to be in the dashboard). research/
        # forward_test_unique_book still defines classify_xhigh/fire_xhigh and
        # forward_test_unique_book.py still logs its columns every run, so the
        # frozen rule keeps accruing evidence -- it just no longer renders.
        live_days = {date.today().isoformat(),
                     (date.today() + timedelta(days=1)).isoformat()}

        # z-GATE STATE FOR THE SIGNALS TABLE, PER DATE.
        #
        # Point-in-time, and the per-date part is the whole correctness of it:
        # zgate_walk decides a bet from its league's bets settled on STRICTLY
        # EARLIER game dates, so a fixture today is judged on history through
        # yesterday, and one tomorrow gets to use today's results too. Reusing
        # the panel's single "state now" figure for both would leak today's
        # settled results onto today's own fixtures -- a small look-ahead, but
        # exactly the kind this file keeps finding after the fact.
        #
        # Only the two live days are computed; the table shows nothing else.
        zgate_drop_by_date = {}
        try:
            for _d in sorted(live_days):
                _st = F.zgate_state([b for b in xlow_bt if b['gd'] < _d])
                zgate_drop_by_date[_d] = sorted(
                    lg for lg, s in _st.items() if s['dropped'])
        except Exception:
            zgate_drop_by_date = {}      # no marks rather than no table

        # ── LAST 24h, both frozen rules ──────────────────────────────────
        # Replaces the Live fills panel (2026-08-23, user request).
        #
        # The window is KICKOFF, not game date. `gd` is a calendar day, so a
        # "last 24h" cut off it would mean anywhere from 1 to 48 hours
        # depending on the hour the page happens to build. load_fixtures
        # derives `ko` for every fixture from elapsed-vs-timestamp, so an exact
        # window is available and there is no reason to approximate one.
        #
        # CLOCKS: live_data timestamps are naive LOCAL time, so `ko` is too and
        # datetime.now() is the right comparison. Do not "fix" this to utcnow().
        #
        # Walks `allfx`, not `prepped`: the scoring set drops anything without a
        # final result, which would silently hide a match that kicked off in the
        # window and has not settled. Those are carried separately as `open` and
        # excluded from the record -- `won` reads False on an unsettled fixture,
        # so folding them in would render every open bet as a loss.
        now_local = datetime.now()
        cutoff = now_local - timedelta(hours=24)

        def _last24(fire, freeze):
            settled, open_ = [], []
            for fx in allfx:
                ko = fx.get('ko')
                if not ko or not (cutoff <= ko <= now_local):
                    continue
                # Belt and braces: a 24h window is always far past both freeze
                # dates, so this never bites today -- but it means the panel can
                # never quote pre-freeze fixtures as evidence if the window is
                # ever widened.
                if freeze and fx['gd'] <= freeze:
                    continue
                b = fire(fx)
                if not b:
                    continue
                (settled if fx['ftr'] is not None else open_).append(b)
            settled.sort(key=lambda b: b.get('ko') or cutoff)
            open_.sort(key=lambda b: b.get('ko') or cutoff)
            wins = sum(1 for b in settled if b['won'])
            return {'stat': F.stat(settled), 'bets': settled, 'open': open_,
                    'w': wins, 'l': len(settled) - wins}

        last24 = {'xlow': _last24(F.fire_xlow_primary, F.FREEZE_DATE)}

        # EQUITY CURVES. Cumulative units, in the order the bets were struck --
        # `prepped` is date-sorted and both classifiers walk it in that order,
        # so the sequence here is the sequence the rule would have lived through.
        # Units, not dollars: the renderer multiplies by PAPER_NOTIONAL so the
        # curve, the table total and the KPI tile cannot drift apart.
        def _curve(fixtures, fire):
            cum, out = 0.0, []
            for fx in fixtures:
                b = fire(fx)
                if not b:
                    continue
                cum += ((1 - b['px']) / b['px']) if b['won'] else -1.0
                out.append((fx['gd'], cum))
            return out

        # Scoped to match the primary above -- an equity curve drawn over a
        # different bet set than the KPI it sits under is the drift this file
        # keeps warning about.
        #
        # BOTH curves walk xlow_bt, the SAME bet objects in the same struck
        # order, so the shadow can be read against the live rule bet for bet.
        # The gated line carries its level forward on a bet the gate sat out,
        # which is what makes a flat stretch mean "excluded" rather than "lost
        # nothing". Rebuilding the variant from its own shorter bet list would
        # put two different timelines on one x-axis. _curve stays for any
        # future single-series curve; this one is the paired case.
        _zkept = None
        try:
            _k, _ = F.zgate_walk(xlow_bt)
            _zkept = {id(b) for b in _k}
        except Exception:
            _zkept = None          # no shadow line rather than no chart
        xlow_curve, xlow_curve_zgate = [], []
        _cum = _cumz = 0.0
        for b in xlow_bt:
            _pnl = ((1 - b['px']) / b['px']) if b['won'] else -1.0
            _cum += _pnl
            xlow_curve.append((b['gd'], _cum))
            if _zkept is not None:
                if id(b) in _zkept:
                    _cumz += _pnl
                xlow_curve_zgate.append((b['gd'], _cumz))
        # Kickoffs for today/tomorrow, keyed for the xlow signals table too --
        # that reads the bet ledger, which has no kickoff field, so it borrows
        # the one derived from live_data here. Both home-key and (home,away)-key
        # are stored: the ledger and live_data agree on team names often but not
        # always, and a home-only hit is still the right fixture on a given day.
        ko_map = {}
        ftr_map = {}
        for fx in allfx:
            if fx['gd'] not in live_days or not fx.get('ko'):
                continue
            r0 = fx['pre'][0] if fx['pre'] else {}
            ko_map[(fx['gd'], fx['league'], r0.get('home'), r0.get('away'))] = fx['ko']
            ko_map[(fx['gd'], fx['league'], r0.get('home'))] = fx['ko']
            # RESULT lookup on the same keys. The signals table reads the bet
            # LEDGER, which only settles when daily_signals runs -- once a day
            # at 04:00 -- so a match finished at lunchtime showed as open until
            # the next morning. The fixture data already knows the result.
            _r = fx.get('ftr_display') or fx.get('ftr')
            if _r:
                ftr_map[(fx['gd'], fx['league'], r0.get('home'), r0.get('away'))] = _r
                ftr_map[(fx['gd'], fx['league'], r0.get('home'))] = _r
        return {'rows': rows, 'leagues': leagues, 'zgate': zgate,
                'zgate_drop_by_date': zgate_drop_by_date,
                'gap_leagues': gap_leagues, 'gap_all': gap_all,
                'n': primary['n'] if primary else 0,
                'last24': last24,
                'bt_leagues': sorted(F.PRIMARY_LEAGUES),
                'ko_map': ko_map,
                'ftr_map': ftr_map,
                'xlow_curve': xlow_curve,
                'xlow_curve_zgate': xlow_curve_zgate,
                'band': F.BAND,
                'freeze': F.FREEZE_DATE, 'error': None}
    except Exception as e:
        return {'rows': {}, 'leagues': {}, 'zgate': None,
                'zgate_drop_by_date': {},
                'gap_leagues': {}, 'gap_all': None,
                'n': 0, 'ko_map': {}, 'ftr_map': {},
                'bt_leagues': [],
                'last24': {'xlow': {'stat': None, 'bets': [], 'open': [], 'w': 0, 'l': 0}},
                'xlow_curve': [], 'xlow_curve_zgate': [],
                'band': (0.05, 0.12),
                'freeze': '?', 'error': str(e)[:300]}


# ── market maker: is the paper book completing, and is completion paying?

def maker_state():
    """Paper market-maker health, framed around the thing that decides P&L.

    The headline is NOT total P&L but COMPLETION RATE. Measured over 242 books:
    a 3-of-3 book runs +1.38c while a 2-of-3 runs -7.62c, because a complete
    book earns the overround outcome-independently and a partial one is a naked
    position. So the dashboard reports how often books complete, split by
    whether they cleared the liquidity gate that predicts it.
    """
    import csv as _csv
    out = {'books': 0, 'complete': 0, 'partial': 0, 'pnl_c': 0.0,
           'by_legs': {}, 'running': False, 'gate': None, 'error': None}
    try:
        import mm_quoter as Q
        out['gate'] = Q.MIN_LEG_VOLUME
        out['live_regime'] = Q.REGIME
    except Exception:
        pass
    try:
        p = Path(r'E:\soccer\research\mm_paper_results.csv')
        if not p.exists():
            return out
        filled = defaultdict(int)
        pnl = defaultdict(float)
        events = set()
        with open(p, encoding='utf-8') as fh:
            for r in _csv.DictReader(fh):
                ev = (r.get('leg_ticker') or '').rsplit('-', 1)[0]
                if not ev:
                    continue
                events.add(ev)
                if str(r.get('filled')).lower() in ('true', '1', 'yes'):
                    filled[ev] += 1
                    try:
                        pnl[ev] += float(r.get('pnl_cents') or 0)
                    except ValueError:
                        pass
        out['books'] = len(events)
        by = defaultdict(lambda: [0, 0.0])
        for ev in events:
            n = filled.get(ev, 0)
            by[n][0] += 1
            by[n][1] += pnl.get(ev, 0.0)
        out['by_legs'] = {n: {'books': v[0], 'pnl_c': v[1]} for n, v in sorted(by.items())}
        out['complete'] = by[3][0]
        out['partial'] = by[1][0] + by[2][0]
        out['pnl_c'] = sum(pnl.values())

        # per-book detail, so each completion bucket can be opened up. Which
        # SIDES filled matters as much as how many: a book stranded on two
        # outcomes is a directional position, and seeing which legs went is how
        # you tell adverse selection from thin liquidity.
        meta = {}
        with open(p, encoding='utf-8') as fh:
            for r in _csv.DictReader(fh):
                ev = (r.get('leg_ticker') or '').rsplit('-', 1)[0]
                if not ev:
                    continue
                m = meta.setdefault(ev, {'div': r.get('div', ''), 'match': r.get('match', ''),
                                         'ts': (r.get('ts') or '')[:10],
                                         'regime': r.get('regime', ''),
                                         'legs': [], 'asks': [], 'vols': []})
                # `or 0` here would coerce a MISSING volume to zero, which is the
                # one thing the sweep below promises not to do: the 53 books that
                # predate the column would land in every "volume >= 0" bucket as
                # genuine zero-volume books and sink it. Missing must stay missing.
                raw = r.get('volume')
                if raw not in (None, ''):
                    try:
                        m['vols'].append(float(raw))
                    except ValueError:
                        pass
                if str(r.get('filled')).lower() in ('true', '1', 'yes'):
                    try:
                        c = float(r.get('pnl_cents') or 0)
                    except ValueError:
                        c = 0.0
                    m['legs'].append({'side': r.get('side', '?'),
                                      'ask': r.get('our_ask', ''),
                                      'fill': r.get('fill_px', ''),
                                      'pnl_c': c})
                try:
                    a = float(r.get('our_ask') or 0)
                    if a:
                        m['asks'].append(a)
                except ValueError:
                    pass
        detail = defaultdict(list)
        for ev, m in meta.items():
            n = filled.get(ev, 0)
            detail[n].append({
                'ev': ev, 'div': m['div'], 'match': m['match'], 'date': m['ts'],
                'regime': m['regime'], 'n': n,
                'gross_c': (sum(m['asks']) - 1) * 100 if len(m['asks']) == 3 else None,
                'sides': ','.join(x['side'] for x in m['legs']) or '—',
                # THINNEST leg, because that is what the gate tests and what
                # strands a book at 2-of-3 -- a book is only as complete-able as
                # its least liquid outcome.
                'vol_min': min(m['vols']) if m['vols'] else None,
                'pnl_c': pnl.get(ev, 0.0),
            })
        for n in detail:
            detail[n].sort(key=lambda x: x['pnl_c'])
        out['detail'] = dict(detail)

        # per-regime totals, computed rather than described, so the explainer
        # cannot drift from what the rows actually say
        reg = defaultdict(lambda: {'legs': 0, 'pnl_c': 0.0, 'books': set()})
        with open(p, encoding='utf-8') as fh:
            for r in _csv.DictReader(fh):
                if str(r.get('filled')).lower() not in ('true', '1', 'yes'):
                    continue
                g = r.get('regime') or '?'
                reg[g]['legs'] += 1
                reg[g]['books'].add((r.get('leg_ticker') or '').rsplit('-', 1)[0])
                try:
                    reg[g]['pnl_c'] += float(r.get('pnl_cents') or 0)
                except ValueError:
                    pass
        out['regimes'] = {g: {'legs': v['legs'], 'books': len(v['books']),
                              'pnl_c': v['pnl_c']}
                          for g, v in sorted(reg.items())}

        # Threshold sweep -- the evidence for where the gate sits, recomputed
        # every render so it cannot drift from the books it describes. Each row
        # is "what if we had only quoted books whose THINNEST leg had >= T
        # traded volume": how many books that leaves, how many completed, and
        # what they made. Books with no volume recorded are excluded rather than
        # treated as zero, which would sink the low buckets with rows that
        # simply predate the column.
        flat = [b for bs in detail.values() for b in bs if b['vol_min'] is not None]
        sweep = []
        for T in (0, 100, 250, 500, 1000, 2500):
            s = [b for b in flat if b['vol_min'] >= T]
            if len(s) < 8:
                continue
            done = sum(1 for b in s if b['n'] == 3)
            tot = sum(b['pnl_c'] for b in s)
            sweep.append({'t': T, 'books': len(s), 'complete': done,
                          'rate': 100.0 * done / len(s), 'pnl_c': tot,
                          'per': tot / len(s)})
        out['sweep'] = sweep

        # The headline pair. Everything else on this panel is detail: a complete
        # book is near-deterministic (+1.78c on SD 1.69 over the first 242 books,
        # t=+8.9) while a partial one is a coin flip with ~25x the spread. So the
        # only number that decides the business is how often books complete, and
        # the only threshold that matters is where completion stops paying for
        # the partials. Computed here rather than written into prose so it cannot
        # drift from the rows. See memory/project_mm_completion_arithmetic.md.
        # Same filter as the sweep below: books with no `volume` recorded are
        # excluded, not counted as zero. Those 53 rows predate the column and are
        # all v1/v2 -- the regime whose quoting rule sat above the market's ask on
        # 86% of legs and essentially could not fill. Pooling them in moves
        # break-even from 61% to 79% purely on legacy behaviour, and would leave
        # the headline contradicting the sweep table directly beneath it.
        allb = [b for bs in detail.values() for b in bs if b['vol_min'] is not None]
        comp_p = [b['pnl_c'] for b in allb if b['n'] == 3]
        part_p = [b['pnl_c'] for b in allb if 0 < b['n'] < 3]
        out['scored'] = len(allb)
        out['unscored'] = sum(len(bs) for bs in detail.values()) - len(allb)

        def _ms(xs):
            if not xs:
                return None
            m = sum(xs) / len(xs)
            sd = ((sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) ** 0.5
                  if len(xs) > 1 else 0.0)
            return {'n': len(xs), 'mean': m, 'sd': sd}

        out['comp'], out['part'] = _ms(comp_p), _ms(part_p)
        if out['comp'] and out['part'] and out['comp']['mean'] != out['part']['mean']:
            spread = out['comp']['mean'] - out['part']['mean']
            out['breakeven'] = 100.0 * (-out['part']['mean'] / spread)
            risked = len(comp_p) + len(part_p)
            p = len(comp_p) / risked
            out['risked'] = risked
            out['live_rate'] = 100.0 * p
            # EV from the two components, each measured far more precisely than
            # realised per-book P&L (SE 3.35c on a ~0.35c effect). Never read the
            # realised column as the answer at these sample sizes.
            out['ev_c'] = p * out['comp']['mean'] + (1 - p) * out['part']['mean']
            # Same arithmetic restricted to books that clear the LIVE gate. The
            # unconditional number above is what the maker did; this is what the
            # rule now running would have done. Both are shown, because either
            # alone misleads: unconditional pools in books v4 would never quote,
            # gated is the in-sample threshold flattering itself.
            if out.get('gate') is not None:
                gb = [b for b in allb if b['n'] > 0 and b['vol_min'] >= out['gate']]
                if len(gb) >= 8:
                    gp = sum(1 for b in gb if b['n'] == 3) / len(gb)
                    out['gated'] = {
                        'n': len(gb), 'rate': 100.0 * gp,
                        'ev_c': gp * out['comp']['mean']
                                + (1 - gp) * out['part']['mean']}
    except Exception as e:
        out['error'] = str(e)[:200]
    return out


def gate_state():
    """What the liquidity gate is doing RIGHT NOW, from mm_quoter's snapshot.

    The gate rejects with a bare `continue`, so without this the only visible
    evidence was the books that got through. This shows the ones turned away and
    by how much, which is the cost side of the threshold.
    """
    g = _jload(Path(r'E:\soccer\research\mm_gate_state.json'), {})
    if not g:
        return None
    rows = g.get('now') or []
    age_m = None
    try:
        ts = datetime.fromisoformat(str(g['ts']))
        age_m = (datetime.now(ts.tzinfo) - ts).total_seconds() / 60.0
    except Exception:
        pass
    daily = g.get('daily') or {}
    adm = sum(len(v.get('admitted') or []) for v in daily.values())
    rej = sum(len(v.get('rejected') or []) for v in daily.values())
    today = sorted(daily)[-1] if daily else None
    t = daily.get(today) or {}
    # Books still ahead of the quoting window. Kept as a separate list, not
    # merged into `rows`, so "N of M books in window pass" keeps meaning what it
    # says -- these have not been ruled on by the gate yet.
    ahead = g.get('ahead') or []
    return {'gate': g.get('gate'), 'regime': g.get('regime'),
            'window_h': g.get('window_h'), 'rows': rows, 'age_m': age_m,
            'ahead': ahead, 'sweep_secs': g.get('sweep_secs'),
            'now_pass': sum(1 for r in rows if r.get('pass')),
            'days': len(daily), 'adm': adm, 'rej': rej,
            'today': today,
            'today_adm': len(t.get('admitted') or []),
            'today_rej': len(t.get('rejected') or [])}


# Raw maker data, offered as downloads from inside the page.
#
# Embedded as data: URIs rather than published as separate files, because the
# page is encrypted and a CSV sitting beside it in the same repo would not be --
# it would hand the whole live dataset to anyone with the URL. Inside the
# payload it is protected by the same passphrase.
#
# mm_live_orders.csv is deliberately NOT offered: it is 3.4MB of every placement
# and cancellation, which would roughly triple the page for a file nobody reads
# interactively. The two that matter are the legs offered and the fills, which
# together are the fill-rate dataset.
CSV_DOWNLOADS = [
    # Put the small analysis-ready file FIRST: it is the one that is actually
    # downloadable. The raw legs log is 31MB and has to be gzipped to embed at
    # all, and a .gz is exactly what an outside analysis environment refuses.
    #
    # THE LEG-LEVEL FILL DATASET IS NO LONGER OFFERED (user, 2026-08-27). It is
    # still BUILT -- the book dataset is derived from it, and _refresh_fill_dataset
    # rebuilds both every page -- it just is not a download. The question worth
    # answering is book COMPLETION, and a leg-level file invites fitting the
    # wrong outcome. research/mm_fill_dataset.csv remains on disk for anyone who
    # wants it.
    ('E:/soccer/research/mm_book_dataset.csv', 'book dataset (analysis-ready)',
     'one row per BOOK we quoted, with the market as it looked AT OPEN -- '
     'opening gross, hours to kickoff, book and thinnest-leg volume, worst-leg '
     'queue, favourite and longshot price -- and legs_filled / complete as the '
     'outcome. Completion is the number the strategy lives or dies on, and a '
     'model fitted on legs answers a different question. Built by '
     'research/mm_book_dataset.py'),
    (r'E:\soccer\research\mm_live_legs.csv', 'legs offered',
     'one row per leg put on the exchange: league, bid/ask/mid and Pinnacle '
     'fair AT PLACEMENT, leg and book volume, queue depth, cents over the '
     'touch and the book budget at that moment'),
    (r'E:\soccer\research\mm_live_realfills.csv', 'fills',
     'every real maker fill, with the queue that was ahead of us and how long '
     'it took'),
]
CSV_MAX_MB = 6.0
# NEVER GZIP THESE, whatever their size. The gzip rule below exists to stop a
# 31MB raw log tripling the page, but "analysis-ready" means it opens anywhere
# -- and a .csv.gz is exactly what an outside analysis environment refuses.
# This file was ~130KB when the rule was written and sat under the threshold by
# luck; it grew past it (413KB on 2026-08-24) and quietly started serving as
# .csv.gz, defeating the reason it exists. Exempt by NAME, not by staying small.
NEVER_GZIP = {'mm_fill_dataset.csv', 'mm_book_dataset.csv'}


def _refresh_fill_dataset():
    """Rebuild BOTH analysis CSVs so the downloads are never stale.

    The book dataset was added 2026-08-26 and NOTHING rebuilt it, so it shipped
    frozen at the row count it happened to have when first generated, while the
    leg dataset beside it refreshed every 30 minutes. A download that silently
    stops moving is worse than no download -- it looks current.

    ORDER MATTERS. The book dataset is derived from the leg dataset, so the leg
    one is rebuilt first and the book builder is told not to redo it
    (rebuild=False); otherwise ~350k log rows get parsed twice per page build
    for the same answer.

    Wrapped per step: these are convenience artifacts and a failure here must
    never take the dashboard down. If one cannot rebuild, the previous file is
    still embedded and simply shows an older row count.
    """
    import importlib.util

    def _run(name, path, **kw):
        try:
            spec = importlib.util.spec_from_file_location(name, path)
            mod = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(mod)
            mod.main(**kw)
        except Exception as e:
            print(f'  [dash] {name} rebuild skipped: {type(e).__name__}: {e}')

    _run('mm_fill_dataset', 'E:/soccer/research/mm_fill_dataset.py')
    _run('mm_book_dataset', 'E:/soccer/research/mm_book_dataset.py',
         rebuild=False)


def csv_downloads():
    import base64
    _refresh_fill_dataset()
    out = ['<div class="note">download raw data: ']
    bits = []
    for path, label, why in CSV_DOWNLOADS:
        p = Path(path)
        try:
            raw = p.read_bytes()
        except OSError:
            continue
        rows = max(0, raw.count(b'\n') - 1)
        # GZIP anything sizeable. Embedding mm_live_legs.csv raw took the page
        # from 160KB to 1.78MB -- an 11x blowup, on a page read from a phone,
        # for a file that gets downloaded rather than displayed. CSV compresses
        # about 10x. The browser saves the gzip bytes as they are, so the name
        # says .csv.gz rather than pretending otherwise.
        import gzip
        name, mime = p.name, 'text/csv'
        if len(raw) > 200_000 and p.name not in NEVER_GZIP:
            raw = gzip.compress(raw, 6)
            name, mime = p.name + '.gz', 'application/gzip'
        mb = len(raw) / 1e6
        if mb > CSV_MAX_MB:
            bits.append(f'<span class="dim" title="{esc(why)}">{esc(label)} '
                        f'({mb:.1f}MB — too large to embed)</span>')
            continue
        b64 = base64.b64encode(raw).decode('ascii')
        bits.append(
            f'<a download="{esc(name)}" title="{esc(why)}" '
            f'href="data:{mime};base64,{b64}">{esc(label)}</a> '
            f'<span class="dim">({rows:,} rows, {len(raw)/1000:.0f}KB'
            + ('&nbsp;gz' if name.endswith('.gz') else '') + ')</span>')
    if not bits:
        return ''
    out.append(' &nbsp;·&nbsp; '.join(bits))
    out.append('</div>')

    # COLUMN GLOSSARY for the analysis-ready file. Collapsed by default -- it is
    # 31 rows and nobody reads it on a phone, but a CSV handed to an outside
    # analysis environment with no field definitions is only half a deliverable.
    # Defined in mm_fill_dataset.FIELD_DOCS so the docs live next to the code
    # that writes the columns and cannot drift from it.
    try:
        import importlib.util
        def _load(name, path):
            sp = importlib.util.spec_from_file_location(name, path)
            m = importlib.util.module_from_spec(sp)
            sp.loader.exec_module(m)
            return m
        _mmbd = _load('_mmbd', 'E:/soccer/research/mm_book_dataset.py')
        # Only the offered download gets a glossary. The leg-level file is no
        # longer a download, so documenting it here would describe something
        # the reader cannot get from this page.
        datasets = [('book dataset', _mmbd.FIELD_DOCS, _mmbd.OUT_COLS,
                     'One row = ONE BOOK we quoted, with every feature taken '
                     'AT OPEN &mdash; what could have been known when the '
                     'decision to open it was made. Anything measured later is '
                     'downstream of the outcome, because a book that fills a '
                     'leg gets re-priced by the budget rule. Outcome is '
                     '<code>complete</code>. <b>Filter on '
                     '<code>legs_quoted==3</code></b>: a book where only two '
                     'legs were ever offered cannot complete, and leaving those '
                     'in understates the rate.')]
    except Exception as e:
        out.append(f'<div class="note dim">column glossary unavailable: '
                   f'{esc(str(e)[:120])}</div>')
        return ''.join(out)

    for name, docs_, cols_, intro in datasets:
        undoc = [c for c in cols_ if c not in dict(docs_)]
        out.append(f'<details class="sec"><summary>{esc(name)} &mdash; column '
                   f'glossary <span class="count">({len(docs_)} fields)</span>'
                   '</summary>')
        out.append(f'<div class="note">{intro}</div>')
        out.append('<div class="card"><table>')
        out.append('<tr><th>column</th><th>meaning</th></tr>')
        for col, why in docs_:
            out.append(f'<tr><td><code>{esc(col)}</code></td>'
                       f'<td class="dim">{esc(why)}</td></tr>')
        out.append('</table></div>')
        if undoc:
            out.append('<div class="note bad">undocumented columns: '
                       + esc(', '.join(undoc)) + '</div>')
        out.append('</details>')
    return ''.join(out)


# Books opened before this measured a DIFFERENT maker: MIN_OPEN_H=6, the 9->5
# book cap and the removal of the xlow-long skip all landed on this date.
STRAND_SINCE = '2026-09-01'
BOOK_CSV = 'E:/soccer/research/mm_book_dataset.csv'


def live_maker():
    """State of the ARMED maker: is it quoting, is it filling, what does it hold?

    THE FAILURE MODE IS SILENT. Every cap in kalshi_maker fails CLOSED -- if the
    exchange cannot be read, collateral reads as infinite and every order is
    refused. That is the safe direction, but it stops quoting without raising
    anything, and "no fills" then looks exactly like a quiet market. So the
    headline here is the AGE OF THE LAST SUCCESSFUL POST, not whether a process
    exists.

    Exchange state is read live rather than from our own logs: the point of a
    monitor is to catch the case where what we believe and what is actually
    resting have diverged.
    """
    import csv as _csv
    import io as _io

    def _rows(path):
        """Parse a maker CSV that may contain NUL bytes from an interrupted write.

        WHY: an interrupted write leaves a block of NULs mid-file, and
        csv.reader raises "line contains NUL" the moment it reaches them. That
        aborted this whole function partway through, so last_post_min kept only
        the value from rows BEFORE the corruption and legs_posted was never
        assigned at all -- the panel reported "last post 25863m ago, 0 legs
        posted" while the maker was quoting normally with 14 orders resting on
        the exchange beside it.

        The corruption is from the 2026-08-17 machine crash: 303 NULs in
        mm_live_orders.csv and 456 in mm_live_legs.csv, both immediately after
        a row stamped 2026-08-17T15:24. The panel had been frozen on that date
        for eighteen days. It fails toward "looks broken" rather than "looks
        fine", which is why it went unnoticed rather than unreported.

        research/mm_fill_dataset._read already does exactly this, which is why
        the research pipeline was never affected. Same treatment here.
        """
        raw = open(path, 'rb').read()
        if b'\x00' in raw:
            raw = raw.replace(b'\x00', b'')
        return _csv.DictReader(_io.StringIO(raw.decode('utf-8', errors='replace')))

    out = {'err': None, 'resting': None, 'positions': [], 'exposure': 0.0,
           'balance': None, 'last_post_min': None, 'placed_1h': 0,
           'rejects_1h': {}, 'legs_posted': 0, 'legs_filled': 0, 'fills': [],
           'strand_rate': None, 'strand_n': 0, 'strand_touched': 0}
    now = datetime.now(timezone.utc)
    try:
        p = Path(r'E:\soccer\research\mm_live_orders.csv')
        if p.exists():
            if True:
                seen = set()
                for r in _rows(p):
                    if str(r.get('live')) != '1':
                        continue
                    ts = None
                    try:
                        ts = datetime.fromisoformat(r['ts'])
                    except Exception:
                        pass
                    act = r.get('action')
                    if act == 'placed':
                        seen.add(r.get('ticker'))
                        if ts:
                            # clamp: a post seconds old can read fractionally
                            # negative against clock skew and "-0m ago" looks
                            # like a bug rather than "just now"
                            age = max(0.0, (now - ts).total_seconds() / 60.0)
                            if out['last_post_min'] is None or age < out['last_post_min']:
                                out['last_post_min'] = age
                            if age <= 60:
                                out['placed_1h'] += 1
                    elif act in ('reject', 'error') and ts and (now - ts).total_seconds() <= 3600:
                        # search the WHOLE detail: the useful phrase sits inside
                        # the exchange's JSON, well past any prefix, so matching
                        # on a truncated head just reports "RuntimeError"
                        d = r.get('detail') or ''
                        if 'post only cross' in d:
                            key = 'post only cross'
                        elif 'collateral' in d or 'cap' in str(r.get('status')):
                            key = 'own cap'
                        else:
                            key = (d.split(':')[0][:40] or act)
                        out['rejects_1h'][key] = out['rejects_1h'].get(key, 0) + 1
                out['legs_posted'] = len(seen)
        f = Path(r'E:\soccer\research\mm_live_realfills.csv')
        if f.exists():
            if True:
                rows = list(_rows(f))
            out['fills'] = rows[-8:]
            out['legs_filled'] = len({r.get('ticker') for r in rows})

        # ── STRAND RATE (2026-09-05, user) ───────────────────────────────
        # Replaces fill rate as the headline. Fill rate counts LEGS; the money
        # is decided by BOOKS, and asymmetrically: a book nobody touched costs
        # nothing, while a book that filled some legs and not the rest is the
        # most expensive state in the strategy (-$0.69 against +$0.09 for a
        # clean one). So the denominator is books that took AT LEAST ONE leg --
        # untouched books are excluded entirely, because they are not a loss and
        # counting them only dilutes the number that matters.
        #
        # Scoped to the CURRENT maker. MIN_OPEN_H=6, the 9->5 book cap and the
        # removal of the xlow-long skip all landed 2026-09-01, so books opened
        # before that measure a maker that no longer exists.
        #
        # Read from mm_book_dataset because `shape` there already separates
        # stranded / clean / ragged / over / unfilled against the EXCHANGE fill
        # record. Rebuilding that from the order log would be a second,
        # disagreeing definition of the one number the strategy lives on.
        try:
            with open(BOOK_CSV, newline='', encoding='utf-8', errors='replace') as fh:
                _bk = [r for r in _csv.DictReader(fh)
                       if r.get('venue') == 'kalshi' and r.get('has_taker') == '0'
                       and (r.get('ts_open') or '') >= STRAND_SINCE]
            _touched = [r for r in _bk if r.get('shape') != 'unfilled']
            _str = [r for r in _touched if r.get('shape') == 'stranded']
            out['strand_touched'] = len(_touched)
            out['strand_n'] = len(_str)
            out['strand_rate'] = (100.0 * len(_str) / len(_touched)) if _touched else None
        except Exception:
            pass
    except Exception as e:
        out['err'] = f'{type(e).__name__}: {e}'
    try:
        import kalshi_trader as _kt
        out['resting'] = len((_kt.get_open_orders() or {}).get('orders', []) or [])
        for pos in (_kt.get_positions() or {}).get('market_positions', []) or []:
            # position_fp -- the bare key does not exist, so this panel was
            # reporting zero exposure and no open positions while the account
            # was actually short eleven legs
            q = float(pos.get('position_fp') or pos.get('position') or 0)
            if q:
                out['positions'].append({'ticker': pos.get('ticker'), 'qty': q})
                out['exposure'] += abs(q)
        b = _kt.get_balance() or {}
        out['balance'] = b.get('balance_dollars')
    except Exception as e:
        out['err'] = (out['err'] or '') + f' exchange: {type(e).__name__}'
    return out


# ── live fills: what the maker ACTUALLY got, off the exchange
#
# REPLACED THE PAPER MARKET-MAKER PANELS on 2026-08-20 (user request). Those
# panels reported mm_settle's numbers, which its own docstring calls a CEILING:
# it counts any trade printed at or above our ask as ours, ignoring queue
# position. The page said so in a warning box and then showed them anyway.
# These rows are real fills from the exchange -- no model, no assumption.

def pretty_event(ev):
    """KXLALIGAGAME-26AUG20RVCALA -> ('LALIGA', 'RVCALA')."""
    parts = (ev or '').split('-')
    lg = parts[0] if parts else ''
    if lg.startswith('KX'):
        lg = lg[2:]
    if lg.endswith('GAME'):
        lg = lg[:-4]
    teams = parts[1] if len(parts) > 1 else ''
    # tickers embed the date as e.g. 26AUG20 before the team codes
    if len(teams) > 7 and teams[:2].isdigit():
        teams = teams[7:]
    return lg, teams


def live_fills(limit=50):
    """Real maker fills, newest first, from mm_live_realfills.csv."""
    import csv as _csv
    import io as _io
    out = {'rows': [], 'n_total': 0, 'n24': 0, 'med_secs': None,
            'med_queue': None, 'error': None}
    p = Path(r'E:\soccer\research\mm_live_realfills.csv')
    try:
        raw = p.read_bytes().replace(b'\x00', b'')
        rows = list(_csv.DictReader(
            _io.StringIO(raw.decode('utf-8', errors='replace'))))
    except OSError as e:
        out['error'] = str(e)[:200]
        return out

    def _f(x):
        try:
            return float(x)
        except (TypeError, ValueError):
            return None

    now = datetime.now(timezone.utc)
    secs, queues = [], []
    parsed = []
    for r in rows:
        ts = r.get('ts') or ''
        try:
            when = datetime.fromisoformat(ts)
        except ValueError:
            when = None
        age_h = (now - when).total_seconds() / 3600.0 if when else None
        if age_h is not None and age_h <= 24:
            out['n24'] += 1
        s, q = _f(r.get('seconds_to_fill')), _f(r.get('queue_ahead_at_post'))
        if s is not None:
            secs.append(s)
        if q is not None:
            queues.append(q)
        lg, teams = pretty_event(r.get('event'))
        parsed.append({
            'ts': ts, 'when': when, 'age_h': age_h,
            'league': lg, 'teams': teams,
            'side': r.get('side') or '',
            'price': _f(r.get('price_cents')),
            'qty': _f(r.get('count')),
            'secs': s, 'queue': q,
        })

    out['n_total'] = len(parsed)
    if secs:
        secs.sort()
        out['med_secs'] = secs[len(secs) // 2]
    if queues:
        queues.sort()
        out['med_queue'] = queues[len(queues) // 2]
    parsed.sort(key=lambda x: x['ts'], reverse=True)
    out['rows'] = parsed[:limit]
    return out


# ── league health: is each league actually wired end-to-end?

def league_health():
    lm = _jload(DATA / 'league_map.json', {})
    tm = _jload(DATA / 'team_map.json', {})
    try:
        import poly_us_prices as pup
        pus_map = pup.LEAGUE_SLUG
    except Exception:
        pus_map = {}
    try:
        import live_monitor as lmon
        gap = lmon.GAP_LEAGUES
    except Exception:
        gap = set()

    # which leagues produced real prices today/tomorrow, per book?
    seen = defaultdict(lambda: {'pin': 0, 'kalshi': 0, 'poly': 0, 'polyus': 0})
    try:
        import csv as _csv
        for d in (date.today(), date.today() + timedelta(days=1)):
            p = LIVE / f'{d.isoformat()}.csv'
            if not p.exists():
                continue
            with open(p, newline='', encoding='utf-8') as fh:
                for r in _csv.DictReader(fh):
                    if (r.get('status') or '') != 'PRE':
                        continue
                    lg = r.get('league') or '?'
                    for label, col in (('pin', 'pin_home'), ('kalshi', 'kalshi_home_mid'),
                                       ('poly', 'poly_home_mid'), ('polyus', 'polyus_home_mid')):
                        try:
                            if r.get(col) not in (None, '') and float(r[col]) > 0:
                                seen[lg][label] += 1
                        except (TypeError, ValueError):
                            pass
    except Exception:
        pass

    out = []
    for code, info in sorted(lm.items()):
        if not isinstance(info, dict):
            continue
        n_teams = sum(1 for v in tm.values()
                      if isinstance(v, dict) and v.get('div') == code)
        n_abbr = sum(1 for v in tm.values()
                     if isinstance(v, dict) and v.get('div') == code and v.get('kalshi'))
        s = seen.get(code, {'pin': 0, 'kalshi': 0, 'poly': 0, 'polyus': 0})
        has_k_series = bool(info.get('kalshi_series'))
        has_poly = bool(info.get('poly_tag'))
        has_pus = code in pus_map
        # can this league fire an xlow signal at all?
        if has_k_series and has_poly:
            can_fire = 'xlow'
        elif has_k_series and has_pus and code in gap:
            can_fire = 'xlow_polyus'
        else:
            can_fire = None
        out.append({
            'code': code, 'name': info.get('name', ''),
            'k_series': has_k_series, 'poly': has_poly, 'polyus': has_pus,
            'teams': n_teams, 'abbr': n_abbr,
            'live': s, 'can_fire': can_fire,
        })
    return out


# ── HTML

CSS = """
/* Dark is the default surface (this page has always been dark and is read at
   night); light is a SELECTED alternate with its own steps, not an auto-inverted
   copy -- flipping a dark palette produces muddy status colours. Both themes
   keep the same semantic tokens so nothing downstream needs to know which is on. */
:root{--bg:#12131a;--card:#1b1d26;--line:#2c2f3d;--ink:#e8e9ee;--dim:#9aa0b4;
--good:#4ec27f;--bad:#e56b6b;--warn:#e0a94a;--acc:#5b9dd9;--hover:#232635}
:root[data-theme="light"]{--bg:#f6f7f9;--card:#ffffff;--line:#e2e5ea;--ink:#1a1c22;
--dim:#63697a;--good:#1a7f37;--bad:#c1272d;--warn:#9a6700;--acc:#2f6fb0;--hover:#f0f2f6}
*{box-sizing:border-box}
body{margin:0;padding:18px;background:var(--bg);color:var(--ink);
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
-webkit-text-size-adjust:100%}
h1{font-size:19px;margin:0 0 2px}
.sub{color:var(--dim);font-size:12px;margin-bottom:14px}
.topbar{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.tools{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
button.tool{background:var(--card);color:var(--dim);border:1px solid var(--line);
border-radius:7px;padding:6px 10px;font:inherit;font-size:12px;cursor:pointer}
button.tool:hover{background:var(--hover);color:var(--ink)}
input.filter{background:var(--card);color:var(--ink);border:1px solid var(--line);
border-radius:7px;padding:6px 10px;font:inherit;font-size:12px;min-width:150px}
input.filter::placeholder{color:var(--dim)}
.kpis{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:6px}
.kpi{background:var(--card);border:1px solid var(--line);border-radius:9px;
padding:11px 14px;min-width:126px;flex:1}
.kpi .lbl{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
.kpi .val{font-size:19px;font-weight:600;margin-top:3px}
/* Sections collapse so a long page can be skimmed. <details>/<summary> is used
   rather than JS toggling so it still works with scripting disabled. */
details.sec{margin:22px 0 0}
details.sec>summary{font-size:15px;font-weight:600;cursor:pointer;list-style:none;
padding:6px 0;display:flex;align-items:center;gap:8px;color:var(--ink)}
details.sec>summary::-webkit-details-marker{display:none}
details.sec>summary::before{content:'\\25B8';color:var(--dim);font-size:11px;
transition:transform .15s;display:inline-block}
details.sec[open]>summary::before{transform:rotate(90deg)}
details.sec>summary:hover{color:var(--acc)}
.count{color:var(--dim);font-weight:400;font-size:13px}
/* Nested drill-down. Its own class so the page-level expand/collapse button
   (which targets details.sec) does not toggle these too -- opening every book
   list at once buries the summary the section exists to show. */
details.sub{margin:8px 0 0 4px}
details.sub>summary{font-size:13px;font-weight:500;cursor:pointer;list-style:none;
padding:5px 0;color:var(--dim);display:flex;align-items:center;gap:7px}
details.sub>summary::-webkit-details-marker{display:none}
details.sub>summary::before{content:'\\25B8';font-size:10px;transition:transform .15s;
display:inline-block}
details.sub[open]>summary::before{transform:rotate(90deg)}
details.sub>summary:hover{color:var(--acc)}
.card{background:var(--card);border:1px solid var(--line);border-radius:9px;
overflow-x:auto;margin-bottom:6px}
table{border-collapse:collapse;width:100%;min-width:520px}
th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;
color:var(--dim);font-weight:600;padding:9px 11px;border-bottom:1px solid var(--line);
white-space:nowrap;position:sticky;top:0;background:var(--card);z-index:1}
th.sortable{cursor:pointer;user-select:none}
th.sortable:hover{color:var(--ink)}
th.sortable::after{content:'\\2195';opacity:.28;margin-left:5px;font-size:10px}
th.sortable.asc::after{content:'\\2191';opacity:.9}
th.sortable.desc::after{content:'\\2193';opacity:.9}
td{padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap}
tbody tr:hover td{background:var(--hover)}
tr:last-child td{border-bottom:none}
.good{color:var(--good)}.bad{color:var(--bad)}.warn{color:var(--warn)}.dim{color:var(--dim)}
.pill{display:inline-block;padding:2px 7px;border-radius:99px;font-size:11px;
border:1px solid var(--line);color:var(--dim)}
.pill.k{border-color:#3d5a80;color:#8fb8e0}
.pill.p{border-color:#4a3d6b;color:#b49ae0}
:root[data-theme="light"] .pill.k{border-color:#9dbbdc;color:#255d99}
:root[data-theme="light"] .pill.p{border-color:#bcabdf;color:#5b3fa0}
/* z-gate SHADOW marker. Warm, not alarming: the row is still a real signal
   that still counts in the frozen forward test -- the pill says a shadow rule
   would have skipped it, which is a note, not a warning. Deliberately NOT
   --bad, which on this page means "lost money". */
.pill.z{border-color:var(--warn);color:var(--warn)}
tr.zdrop td{opacity:.72}
.grouphdr td{background:var(--hover);border-top:2px solid var(--line);
font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);
font-weight:600;padding:7px 11px}
tbody tr.grouphdr:hover td{background:var(--hover)}
.bar{height:6px;background:var(--line);border-radius:99px;overflow:hidden;margin-top:7px}
.bar>i{display:block;height:100%;background:var(--acc)}
.note{color:var(--dim);font-size:12px;margin:6px 0 0}
tr.hidden{display:none}
.empty{color:var(--dim);font-size:12px;padding:10px 11px}
/* ── Equity curve ─────────────────────────────────────────────────────────
   One series per chart, so no legend -- the heading names it. Sign is carried
   GEOMETRICALLY, by position against the dashed zero line, because the good/bad
   pair separates by only ~4.9 deutan dE: colour here reinforces a cue that is
   already there, it is never the only one. Marks stay thin and the axes
   recessive so the line is the loudest thing in the figure. */
.eq{width:100%;height:auto;display:block}
.eq .eq-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
/* SECOND series (a shadow variant drawn against the real one). Distinguished
   by DASH, not by hue -- same reason the sign is encoded by position: a reader
   who cannot separate the two colours still sees solid vs dashed. Thinner and
   dimmer so the rule that is actually live stays the loudest line. */
.eq .eq-line2{fill:none;stroke-width:1.5;stroke-dasharray:4 3;opacity:.85;
  stroke-linejoin:round;stroke-linecap:round}
.eq .eq-dot2{stroke:var(--card);stroke-width:2}
.eqkey{display:flex;gap:12px;flex-wrap:wrap;margin:-2px 0 6px;
  color:var(--dim);font-size:11px;text-transform:none;letter-spacing:0}
.eqkey i{display:inline-block;width:14px;height:0;vertical-align:middle;
  border-top-width:2px;margin-right:5px}
/* the zero rule is the reference the whole chart is read against, so it is the
   one piece of chrome allowed to be visible rather than merely present */
.eq .eq-zero{stroke:var(--dim);stroke-width:1;stroke-dasharray:3 3;opacity:.55}
.eq .eq-ax{fill:var(--dim);font-size:10px}
.eq .eq-end{fill:var(--ink);font-size:11px;font-weight:600}
.eq .eq-cross{stroke:var(--dim);stroke-width:1;stroke-dasharray:2 2}
.eq .eq-dot{stroke:var(--card);stroke-width:2}
.eq .eq-tip{fill:var(--ink);font-size:11px;font-weight:600}
.eq .eq-tipbg{fill:var(--card);stroke:var(--line);stroke-width:1;rx:4}
/* The backed side, shown IN the match rather than as its own column. Position
   already says home or away (before or after "vs"), so this only has to mark
   WHICH team -- one accent, no colour code to learn, and it cannot be confused
   with the good/bad used for outcomes. */
.pick{color:var(--acc);font-weight:600}
/* Provenance dot: this result came from a settled market, not the match record.
   Deliberately faint -- it is a footnote about where a number came from, not a
   warning about the number. */
.prov{opacity:.45;margin-left:3px;cursor:help}
/* Age of the build, filled in by the browser. Quiet when fresh; red once the
   page has missed two publish cycles, which is the only case worth a colour. */
.age{opacity:.7}
.age.stale{color:#e5534b;opacity:1;font-weight:600}
/* CLV rides with its ask: they are one thought -- what it costs and how far
   below fair that is -- and two columns for it cost width a phone has not got.
   Inline on a wide screen, stacked under the price on a narrow one. */
.clvsub{margin-left:7px}
.eqwrap{background:var(--card);border:1px solid var(--line);border-radius:9px;
padding:10px 12px 6px;margin:0 0 8px}
.eqhead{color:var(--dim);font-size:11px;text-transform:uppercase;
letter-spacing:.04em;margin-bottom:2px}

/* ── Mobile ───────────────────────────────────────────────────────────────
   Wide tables shed DETAIL rather than forcing a sideways scroll: columns are
   tagged by priority and drop out as the screen narrows, so what survives on a
   phone is the answer (which match, which side, what price, what happened) and
   what goes is the supporting evidence. Horizontal scroll still exists as a
   fallback for the tables that stay wide.
     .opt   secondary  -> hidden below 900px
     .opt2  tertiary   -> hidden below 640px  */
@media(max-width:900px){
  .opt{display:none}
  table{min-width:0}
}
@media(max-width:640px){
  body{padding:12px}
  .kpi{min-width:calc(50% - 5px)}
  .topbar{flex-direction:column;align-items:stretch}
  .tools{width:100%}
  input.filter{flex:1}
  .opt2{display:none}
  h1{font-size:18px}
  /* long match names wrap instead of stretching the row off-screen */
  td{white-space:normal}
  /* Reclaim the last ~40px from CHROME rather than from content. At 11px of
     horizontal padding a five-column table spends ~110px on gutters; 6px still
     reads as separated and buys back a whole column's worth of width. */
  th,td{padding:8px 6px;font-size:13px}
  th{font-size:11px}
  /* The match is the widest column and, left alone, the table's auto layout
     gives it whatever the longest fixture name asks for. The cap has to sit on
     an INNER inline-block: max-width on a <td> is treated as a suggestion by
     the auto table algorithm and was being ignored outright. Two short lines
     cost height, which scrolls naturally, instead of width, which does not. */
  .mcell{white-space:normal}
  .mw{display:inline-block;max-width:112px}
  /* kickoff stacks UNDER the date instead of beside it: same information, ~35px
     narrower, and height is the axis that scrolls for free. Same trick for the
     CLV under its ask. */
  .kotime{display:block}
  .clvsub{display:block;margin-left:0}
  th{white-space:normal}
  td,th{padding:8px 7px;font-size:13px}
  /* touch targets: summaries and buttons get real height */
  details.sec>summary{padding:10px 0}
  details.sub>summary{padding:9px 0}
  button.tool{padding:9px 12px}
}
@media print{button.tool,input.filter{display:none}details.sec{display:block}}
"""

# Interaction layer. No dependencies -- this page is served as a static file from
# GitHub Pages, so anything external would be a broken-CDN failure mode on a page
# nobody is watching.
JS = """
(function(){
  // Theme: honour the OS preference, remember an explicit choice.
  var root=document.documentElement, KEY='xlow-theme';
  var saved=null; try{saved=localStorage.getItem(KEY);}catch(e){}
  // Dark is the default regardless of OS preference -- this page is read on a
  // dark terminal setup and its palette was built for the dark surface first.
  // An explicit choice still wins and persists.
  root.setAttribute('data-theme', saved || 'dark');
  var tbtn=document.getElementById('themeBtn');
  function label(){ tbtn.textContent = root.getAttribute('data-theme')==='light'?'Dark':'Light'; }
  if(tbtn){ label(); tbtn.onclick=function(){
    root.setAttribute('data-theme', root.getAttribute('data-theme')==='light'?'dark':'light');
    try{localStorage.setItem(KEY, root.getAttribute('data-theme'));}catch(e){}
    label(); }; }

  // Sort. Numeric where the whole column parses as a number (after stripping
  // %,$,+,commas) so "-14.7%" and "$1,797.89" sort as magnitudes, not strings.
  function val(td){
    // An explicit data-sort wins. Needed where the DISPLAY is not the sort key:
    // "A · +$34.05" would otherwise parse to nothing and fall back to sorting
    // alphabetically by result letter, which is not what anyone means by
    // "sort the result column".
    if(td.dataset && td.dataset.sort!==undefined && td.dataset.sort!==''){
      var d=parseFloat(td.dataset.sort);
      return isNaN(d)?null:d;
    }
    var t=(td.textContent||'').trim();
    var n=parseFloat(t.replace(/[$,%+\\s]/g,'').replace(/^\\((.*)\\)$/,'-$1'));
    return isNaN(n)?null:n;
  }
  document.querySelectorAll('table').forEach(function(tb){
    var head=tb.tHead||tb.rows[0]&&tb.rows[0].parentNode;
    var hr=tb.querySelector('tr'); if(!hr) return;
    var body=tb.tBodies[0]||tb;
    Array.prototype.forEach.call(hr.cells,function(th,i){
      if(th.tagName!=='TH') return;
      th.classList.add('sortable');
      th.onclick=function(){
        var all=Array.prototype.slice.call(body.rows);
        // Rows marked .nosort belong to a different rule (or are separators).
        // They keep their original order and stay BELOW the sorted block --
        // sorting them in with the primary implies a pooling that is wrong.
        var pinned=all.filter(function(r){ return r.classList.contains('nosort'); });
        var rows=all.filter(function(r){
          return r!==hr && !r.classList.contains('nosort')
                 && r.cells.length===hr.cells.length; });
        if(!rows.length) return;
        var dir = th.classList.contains('asc')?-1:1;
        Array.prototype.forEach.call(hr.cells,function(o){o.classList.remove('asc','desc');});
        th.classList.add(dir===1?'asc':'desc');
        rows.sort(function(a,b){
          var x=val(a.cells[i]), y=val(b.cells[i]);
          if(x!==null&&y!==null) return (x-y)*dir;
          return (a.cells[i].textContent.trim()).localeCompare(b.cells[i].textContent.trim())*dir;
        });
        rows.forEach(function(r){ body.appendChild(r); });
        pinned.forEach(function(r){ body.appendChild(r); });
      };
    });
  });

  // Filter: hides non-matching rows across every table at once, so typing a
  // league code or team narrows the whole page rather than one section.
  var f=document.getElementById('filterBox');
  if(f){ f.oninput=function(){
    var q=f.value.toLowerCase().trim();
    document.querySelectorAll('table').forEach(function(tb){
      var hr=tb.querySelector('tr');
      Array.prototype.forEach.call(tb.rows,function(r){
        if(r===hr) return;
        if(r.cells.length===1){ return; }   // keep spanning note rows
        r.classList.toggle('hidden', !!q && r.textContent.toLowerCase().indexOf(q)<0);
      });
    });
    if(q) document.querySelectorAll('details.sec').forEach(function(d){d.open=true;});
  }; }

  // Equity hover: crosshair + tooltip. A line chart that cannot be read at a
  // point is half a chart -- the end label alone tells you where it finished,
  // not when it drew down. Nearest-index by pixel x, so the whole plot area is
  // a hit target rather than each 2px vertex.
  document.querySelectorAll('svg.eq').forEach(function(svg){
    var xs=(svg.getAttribute('data-x')||'').split(',').map(Number),
        vs=(svg.getAttribute('data-v')||'').split(',').map(Number),
        ds=(svg.getAttribute('data-d')||'').split(','),
        g=svg.querySelector('.eq-hov'), hit=svg.querySelector('.eq-hit');
    if(!xs.length||!g||!hit) return;
    var cross=g.querySelector('.eq-cross'), dot=g.querySelector('.eq-dot'),
        tip=g.querySelector('.eq-tip'), bg=g.querySelector('.eq-tipbg'),
        line=svg.querySelector('.eq-line'), pathEl=line&&line.getAttribute('d');
    // second series, when the chart has one. Same index space as the first --
    // equity_svg guarantees they are sampled on the same bet sequence -- so
    // one nearest-index lookup serves both.
    var v2raw=svg.getAttribute('data-v2')||'',
        v2=v2raw?v2raw.split(',').map(Number):null,
        dot2=g.querySelector('.eq-dot2'),
        line2=svg.querySelector('.eq-line2'),
        pathEl2=line2&&line2.getAttribute('d');
    function segAt(d,i){
      // y comes from the path string rather than being re-emitted: one source
      var m=d.split(' L'); var seg=(i===0?m[0].slice(1):m[i]);
      return seg.split(',').map(Number);
    }
    function ptAt(i){ return segAt(pathEl,i); }
    function move(ev){
      var r=svg.getBoundingClientRect();
      var sx=(ev.clientX-r.left)*(640/r.width);
      var best=0, bd=1e9;
      for(var i=0;i<xs.length;i++){ var d=Math.abs(xs[i]-sx); if(d<bd){bd=d;best=i;} }
      var p=ptAt(best);
      g.style.display='';
      cross.setAttribute('x1',p[0]); cross.setAttribute('x2',p[0]);
      dot.setAttribute('cx',p[0]); dot.setAttribute('cy',p[1]);
      var txt=ds[best]+'  '+(vs[best]>=0?'+':'')+vs[best].toFixed(2);
      if(v2&&pathEl2&&dot2){
        var p2=segAt(pathEl2,best);
        dot2.setAttribute('cx',p2[0]); dot2.setAttribute('cy',p2[1]);
        dot2.style.display='';
        txt+='   /  '+(v2[best]>=0?'+':'')+v2[best].toFixed(2);
      }
      tip.textContent=txt;
      var w=txt.length*6.2+10, tx=Math.min(Math.max(p[0]-w/2,2),640-w-2);
      bg.setAttribute('x',tx); bg.setAttribute('y',2); bg.setAttribute('width',w);
      tip.setAttribute('x',tx+w/2); tip.setAttribute('y',14);
      tip.setAttribute('text-anchor','middle');
    }
    hit.addEventListener('mousemove',move);
    hit.addEventListener('touchmove',function(e){
      if(e.touches&&e.touches[0]){ move(e.touches[0]); e.preventDefault(); }
    },{passive:false});
    hit.addEventListener('mouseleave',function(){ g.style.display='none'; });
    hit.addEventListener('touchend',function(){ g.style.display='none'; });
  });

  // Default sort, applied only to tables that ASK for it via data-defaultsort.
  // Not blanket: "Recent books" is deliberately newest-first and the regime
  // table is in v1..v4 order, so sorting every table by column 0 would undo
  // orderings that were chosen on purpose.
  document.querySelectorAll('table[data-defaultsort]').forEach(function(tb){
    var i=parseInt(tb.getAttribute('data-defaultsort'),10)||0;
    var hr=tb.querySelector('tr');
    if(hr && hr.cells[i] && hr.cells[i].onclick) hr.cells[i].onclick();
  });

  var eb=document.getElementById('expandBtn');
  if(eb){ eb.onclick=function(){
    var any=Array.prototype.some.call(document.querySelectorAll('details.sec'),
      function(d){return !d.open;});
    document.querySelectorAll('details.sec').forEach(function(d){d.open=any;});
    eb.textContent = any?'Collapse all':'Expand all';
  }; }
})();
"""


def esc(s):
    return (str(s).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'))


def pct(v, digits=1):
    if v is None:
        return '<span class="dim">—</span>'
    cls = 'good' if v > 0 else ('bad' if v < 0 else 'dim')
    return f'<span class="{cls}">{v:+.{digits}f}%</span>'


def date_sort_key(datestr, ko=None):
    """Numeric sort key for a Date cell: YYYYMMDDHHMM.

    The table sorter parses cell TEXT as a number, and "08-14" parses to 8 --
    so every row in the same month tied and the Date column silently did
    nothing at all. Adding the kickoff did not break it; it made an existing
    tie visible. data-sort is the documented escape hatch for a cell whose
    display is not its sort key, so the column now orders by date THEN kickoff.
    """
    try:
        d = int(str(datestr).replace('-', ''))
    except (TypeError, ValueError):
        return ''
    return str(d * 10000 + (ko.hour * 100 + ko.minute if ko else 0))


def money(v, digits=2):
    if v is None:
        return '<span class="dim">—</span>'
    cls = 'good' if v > 0 else ('bad' if v < 0 else '')
    return f'<span class="{cls}">${v:,.{digits}f}</span>'


def _ax_money(v):
    """Axis money label. The sign goes OUTSIDE the currency symbol: "$-106" is
    the kind of thing that gets read as $106 at 10px."""
    return f'{"-" if v < 0 else ""}${abs(v):,.0f}'


def equity_svg(points, cid, title, overlay=None, keys=None):
    """Inline SVG equity curve for one paper rule, optionally with a variant.

    ONE series by default, and then there is no legend -- the heading names it,
    and a legend box for a single line is noise. Sign is encoded by POSITION
    against a dashed zero line, not by hue: --good and --bad separate by only
    ~4.9 deutan dE, so a reader who cannot tell them apart still sees the curve
    sitting below zero. Colour reinforces; geometry and the end label carry it.

    `overlay` adds a SECOND series and, with it, the legend the single-series
    case does not need. It must be sampled on the SAME bet sequence as
    `points` -- same length, same dates, one entry per bet of the primary rule
    -- because the two are meant to be read as "what the same run would have
    looked like". A variant that skips a bet carries its previous level
    forward, so the dashed line going flat IS the variant sitting that bet out.
    Building it on its own shorter sequence instead would silently compress its
    x-axis and make two different timelines look like one.

    The variant is separated by DASH rather than hue, for the same reason the
    sign is: solid vs dashed survives any colour vision.

    `points` is [(game_date, cumulative_units)] in bet order; units are scaled
    by PAPER_NOTIONAL here so the curve cannot drift from the table or the tile.
    """
    if len(points) < 2:
        return ''
    # Start the curve at ZERO, before the first bet. Without an origin the line
    # opens at whatever the first bet returned -- a rule began at $33 after one
    # winner, which reads as starting capital rather than a first result.
    points = [(points[0][0], 0.0)] + list(points)
    vals = [v * PAPER_NOTIONAL for _, v in points]
    vals2 = None
    if overlay and len(overlay) == len(points) - 1:
        vals2 = [0.0] + [v * PAPER_NOTIONAL for _, v in overlay]
    lo, hi = min(vals + (vals2 or []) + [0.0]), max(vals + (vals2 or []) + [0.0])
    if hi - lo < 1e-9:
        hi = lo + 1.0
    W, H = 640.0, 120.0
    L, R, T, B = 46.0, 62.0, 10.0, 20.0
    pw, ph = W - L - R, H - T - B

    def px(i):
        return L + (pw * i / (len(vals) - 1))

    def py(v):
        return T + ph * (hi - v) / (hi - lo)

    pts = [(px(i), py(v)) for i, v in enumerate(vals)]
    line = 'M' + ' L'.join(f'{x:.1f},{y:.1f}' for x, y in pts)
    base = py(0.0)
    cls = 'good' if vals[-1] > 0 else ('bad' if vals[-1] < 0 else 'dim')
    col = f'var(--{cls})' if cls != 'dim' else 'var(--dim)'
    ex, ey = pts[-1]
    # data for the hover layer: pixel xs, dollar values, dates
    dx = ','.join(f'{x:.1f}' for x, _ in pts)
    dv = ','.join(f'{v:.2f}' for v in vals)
    dd = ','.join(esc(str(g)) for g, _ in points)
    # ── optional second series ──
    svg2 = key = ''
    dv2 = ''
    col2 = 'var(--acc)'
    if vals2 is not None:
        pts2 = [(px(i), py(v)) for i, v in enumerate(vals2)]
        line2 = 'M' + ' L'.join(f'{x:.1f},{y:.1f}' for x, y in pts2)
        ex2, ey2 = pts2[-1]
        dv2 = ','.join(f'{v:.2f}' for v in vals2)
        # end labels can collide when the two finish close together; nudge the
        # variant's clear rather than letting two numbers overprint
        _dy = 4.0 if abs(ey2 - ey) > 11 else (13.0 if ey2 >= ey else -6.0)
        svg2 = (f'<path class="eq-line2" d="{line2}" stroke="{col2}"/>'
                f'<circle cx="{ex2:.1f}" cy="{ey2:.1f}" r="3" fill="{col2}"/>'
                f'<text class="eq-end" x="{ex2+8:.1f}" y="{ey2+_dy:.1f}" '
                f'fill="{col2}">{vals2[-1]:+,.0f}</text>')
        k1, k2 = keys or ('rule', 'variant')
        key = (f'<div class="eqkey">'
               f'<span><i style="border-top:2px solid {col}"></i>{esc(k1)}</span>'
               f'<span><i style="border-top:2px dashed {col2}"></i>{esc(k2)}</span>'
               f'</div>')
    return (
        f'<div class="eqwrap"><div class="eqhead">{esc(title)}</div>{key}'
        f'<svg class="eq" viewBox="0 0 {W:.0f} {H:.0f}" role="img" '
        f'aria-label="{esc(title)}: {len(vals)} bets, ending {vals[-1]:+,.2f} '
        f'dollars paper" data-eq="{cid}" data-x="{dx}" data-v="{dv}" '
        f'data-v2="{dv2}" data-d="{dd}">'
        # NO area fill. A curve that crosses zero dozens of times fills into a
        # murky band that reads as one long loss; the line against the zero rule
        # says the same thing without the mud.
        f'<line class="eq-zero" x1="{L}" y1="{base:.1f}" x2="{W-R:.1f}" y2="{base:.1f}"/>'
        # variant UNDER the live rule: if they overlap, the line that is
        # actually being traded is the one that stays readable
        f'{svg2}'
        f'<path class="eq-line" d="{line}" stroke="{col}"/>'
        f'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="4" fill="{col}"/>'
        # axis extremes only -- a value on every point is noise, and these two
        # are what bound the reader's sense of scale
        f'<text class="eq-ax" x="{L-6:.0f}" y="{py(hi)+4:.1f}" text-anchor="end">'
        f'{_ax_money(hi)}</text>'
        f'<text class="eq-ax" x="{L-6:.0f}" y="{py(lo)+4:.1f}" text-anchor="end">'
        f'{_ax_money(lo)}</text>'
        f'<text class="eq-ax" x="{L:.0f}" y="{H-6:.0f}">{esc(points[0][0])}</text>'
        f'<text class="eq-ax" x="{W-R:.1f}" y="{H-6:.0f}" text-anchor="end">'
        f'{esc(points[-1][0])}</text>'
        f'<text class="eq-end" x="{ex+8:.1f}" y="{ey+4:.1f}">{vals[-1]:+,.0f}</text>'
        f'<g class="eq-hov" style="display:none">'
        f'<line class="eq-cross" y1="{T}" y2="{T+ph:.1f}"/>'
        f'<circle class="eq-dot" r="4" fill="{col}"/>'
        f'<circle class="eq-dot2" r="3" fill="{col2}" style="display:none"/>'
        f'<rect class="eq-tipbg" rx="4" height="17"/><text class="eq-tip"/></g>'
        f'<rect x="{L}" y="{T}" width="{pw:.1f}" height="{ph:.1f}" fill="transparent" '
        f'class="eq-hit" style="cursor:crosshair"/>'
        f'</svg></div>')


def build():
    today = date.today()
    tom = today + timedelta(days=1)
    v = venue_state()
    subs = subaccounts()
    ft = forward_test()
    sigs, sigs_hidden = scope_signals(
        signals_for({today.isoformat(), tom.isoformat()},
                    ftr_map=ft.get('ftr_map')), ft)
    health = league_health()

    k_pos = v['kalshi']['positions']
    p_pos = v['polyus']['positions']
    at_risk = sum(x['cost'] for x in k_pos) + sum(x['cost'] for x in p_pos)
    p_val = sum(x['value'] for x in p_pos if x['value'] is not None)
    p_cost = sum(x['cost'] for x in p_pos if x['value'] is not None)

    # Real document shell. This page was a bare fragment -- no doctype, no head,
    # and critically NO VIEWPORT META, so phones assumed a ~980px desktop
    # viewport and scaled the whole thing down. That single missing tag was the
    # bulk of "not mobile friendly"; the media queries below only work once it
    # is present.
    h = ['<!doctype html><html lang="en"><head>',
         '<meta charset="utf-8">',
         '<meta name="viewport" content="width=device-width, initial-scale=1">',
         '<meta name="color-scheme" content="dark light">',
         '<title>Soccer xlow tracker</title>',
         f'<style>{CSS}</style>', '</head><body>']
    h.append('<div class="topbar"><div>')
    h.append('<h1>Soccer xlow tracker</h1>')
    # The build time is stamped into the page AND read back by the browser as an
    # age. A snapshot time alone does not answer the only question a stale-looking
    # page raises -- "is this old, or did nothing happen?" -- because you have to
    # know the current time and the publish cadence to interpret it. The age does,
    # and it turns red once the page is older than two publish cycles.
    h.append(f'<div class="sub">snapshot '
             f'<span id="built" data-built="{datetime.now().isoformat(timespec="seconds")}">'
             f'{datetime.now().strftime("%Y-%m-%d %H:%M")}</span>'
             f'<span id="age" class="age"></span> '
             f'· frozen rule since {ft["freeze"]} · caps $1/order both venues</div>')
    h.append('<script>(function(){'
             'var e=document.getElementById("built"),a=document.getElementById("age");'
             'if(!e||!a)return;'
             'function tick(){'
             ' var b=new Date(e.getAttribute("data-built")),m=Math.floor((Date.now()-b)/60000);'
             ' if(isNaN(m))return;'
             ' a.textContent=" ("+(m<1?"just now":m<60?m+"m old":(m/60).toFixed(1)+"h old")+")";'
             ' a.className="age"+(m>65?" stale":"");'
             '}tick();setInterval(tick,30000);})();</script>')
    h.append('</div><div class="tools">')
    h.append('<input class="filter" id="filterBox" type="search" '
             'placeholder="Filter league / team…" aria-label="Filter rows">')
    h.append('<button class="tool" id="expandBtn">Collapse all</button>')
    h.append('<button class="tool" id="themeBtn">Light</button>')
    h.append('</div></div>')

    # KPI row
    prim = ft['rows'].get('xlow (PRIMARY · scoped)')
    h.append('<div class="kpis">')
    _kv = v["kalshi"]
    if _kv.get("balance") is None:
        h.append('<div class="kpi"><div class="lbl">Kalshi total</div>'
                 '<div class="val"><span class="dim">err</span></div></div>')
    else:
        _by = _kv.get("cash_by_sub") or {}
        _subtxt = ' · '.join(
            ('primary ' if s == 0 else f'sub{s} ') + money(c)
            for s, c in sorted(_by.items()))
        h.append(f'<div class="kpi"><div class="lbl">Kalshi total</div>'
                 f'<div class="val">{money(_kv["balance"])}</div>'
                 f'<div class="lbl">cash {money(_kv.get("cash", 0))} · '
                 f'pending {money(_kv.get("pending", 0))}</div>'
                 f'<div class="lbl dim">{_subtxt}</div></div>')
    _pv = v["polyus"]
    if _pv.get("balance") is None:
        h.append('<div class="kpi"><div class="lbl">Poly-US total</div>'
                 '<div class="val"><span class="dim">err</span></div></div>')
    else:
        h.append(f'<div class="kpi"><div class="lbl">Poly-US total</div>'
                 f'<div class="val">{money(_pv["balance"])}</div>'
                 f'<div class="lbl">cash {money(_pv.get("cash", 0))} · '
                 f'pending {money(_pv.get("pending", 0))}</div></div>')
    h.append(f'<div class="kpi"><div class="lbl">At risk</div><div class="val">{money(at_risk)}'
             f'</div><div class="lbl">{len(k_pos)+len(p_pos)} open</div></div>')
    if prim:
        h.append(f'<div class="kpi"><div class="lbl">Fwd ROI/bet</div>'
                 f'<div class="val">{pct(prim["roi"])}</div>'
                 f'<div class="lbl">n={prim["n"]} · z={prim["z"]:+.2f}</div></div>')
        gap_pp = prim['wr'] - prim['iwr']
        h.append(f'<div class="kpi"><div class="lbl">WR vs implied</div>'
                 f'<div class="val">{pct(gap_pp)}</div>'
                 f'<div class="lbl">{prim["wr"]:.1f}% vs {prim["iwr"]:.1f}%</div></div>')
    # PAPER LEDGERS, one tile per frozen rule. Labelled "not real" in the tile
    # itself rather than only in a note further down: a dollar figure sitting in
    # a KPI row next to actual Kalshi and Poly-US cash is exactly the thing that
    # gets misread as money six months from now.
    #
    # Reads the frozen forward-test stat and multiplies by PAPER_NOTIONAL, so
    # this tile is the same arithmetic as the 24h tile below over a longer
    # window. Carries its own n rather than being pooled with anything else.
    for label, s, since in (('xlow paper', prim, ft.get('freeze')),):
        if not (s and s.get('n')):
            continue
        net = s['roi'] / 100.0 * s['n'] * PAPER_NOTIONAL
        bank = PAPER_BANKROLL_START + net
        bank_cls = ('good' if bank > PAPER_BANKROLL_START
                    else ('bad' if bank < PAPER_BANKROLL_START else 'dim'))
        h.append(f'<div class="kpi"><div class="lbl">{label} '
                 f'<span class="dim">(not real)</span></div>'
                 f'<div class="val">{money(net)}</div>'
                 # NOT money() for either figure. money() colours by SIGN, and
                 # a bankroll is only ever positive here, so a book $89 DOWN
                 # printed a green $910.91 -- the one thing the tile
                 # exists to show. Colour is keyed to the opening balance
                 # instead, and the opening figure itself stays dim.
                 f'<div class="lbl">bankroll '
                 f'<b><span class="{bank_cls}">${bank:,.2f}</span></b> '
                 f'<span class="dim">from ${PAPER_BANKROLL_START:,.2f}</span></div>'
                 f'<div class="lbl">n={s["n"]} · {s["roi"]:+.1f}% · '
                 f'WR {s["wr"]:.0f}% vs {s["iwr"]:.0f}% implied</div>'
                 f'<div class="lbl dim">frozen {esc(str(since or "?"))}</div></div>')
    h.append('</div>')
    # The n>=100 checkpoint progress bar lived here. Removed 2026-08-16: the
    # forward test passed 100 long ago and sat at 328/100 with a permanently
    # full bar, which is not progress, it is decoration. The checkpoint has been
    # REACHED -- what matters now is the review it was supposed to trigger, and
    # a bar pinned at 100% was actively hiding that.

    # ── LIVE MAKER ────────────────────────────────────────────────────────
    # First thing on the page because it is the only section with real money
    # behind it. Leads with staleness, not with P&L: the caps fail closed, so
    # the way this breaks is by going quiet, and a quiet maker looks exactly
    # like a quiet market until you check when it last posted.
    lm = live_maker()
    stale = lm['last_post_min'] is None or lm['last_post_min'] > 5
    hdr = ('<span class="bad">STALE</span>' if stale
           else '<span class="good">live</span>')
    h.append(f'<details class="sec" open><summary>Live maker {hdr} '
             f'<span class="count">({lm["resting"] if lm["resting"] is not None else "?"}'
             ' resting)</span></summary>')
    h.append('<div class="card"><table>')
    h.append('<tr><th>Last post</th><th>Resting</th><th class="opt">Placed 1h</th>'
             '<th>Legs posted</th><th>Filled</th><th>Strand rate</th>'
             '<th class="opt">Exposure</th><th>Balance</th></tr>')
    lp = ('never' if lm['last_post_min'] is None
          else f'{lm["last_post_min"]:.0f}m ago')
    # n shown beside the rate on purpose: at a handful of touched books the
    # percentage moves 25pp on one outcome, and a bare "50%" reads as a
    # measurement rather than the coin flip it currently is.
    fr = ('<span class="dim">&mdash;</span>' if lm.get('strand_rate') is None
          else f'{lm["strand_rate"]:.0f}%'
               f'<span class="clvsub">{lm["strand_n"]}/{lm["strand_touched"]}</span>')
    h.append(f'<tr><td class="{"bad" if stale else "good"}">{lp}</td>'
             f'<td>{lm["resting"] if lm["resting"] is not None else "?"}</td>'
             f'<td class="dim opt">{lm["placed_1h"]}</td>'
             f'<td>{lm["legs_posted"]}</td><td>{lm["legs_filled"]}</td>'
             f'<td><b>{fr}</b></td>'
             f'<td class="dim opt">{lm["exposure"]:.0f} ct</td>'
             f'<td>${esc(str(lm["balance"] or "?"))}</td></tr>')
    h.append('</table></div>')
    h.append(csv_downloads())
    if lm['rejects_1h']:
        bits = ' · '.join(f'{k} &times;{v}' for k, v in
                          sorted(lm['rejects_1h'].items(), key=lambda kv: -kv[1]))
        h.append(f'<div class="note">rejects in the last hour: {esc(bits)}'
                 ' — <b>post only cross</b> is the guard working: the book moved '
                 'between reading the ask and sending, so the exchange refused an '
                 'order that would have crossed.</div>')
    if lm['positions']:
        h.append('<div class="note">open: ' + ' · '.join(
            f'{esc(p["ticker"].rsplit("-", 1)[-1])} {p["qty"]:+.0f}'
            for p in lm['positions']) + '</div>')
    h.append('<div class="note"><b>Strand rate</b>, not fill rate: books that '
             'took at least one leg and did NOT complete, over books that took '
             'at least one leg. A book nobody touched is excluded &mdash; it '
             'cost nothing, and counting it only dilutes the number. A strand '
             'costs about <b>&minus;$0.69</b> against <b>+$0.09</b> for a clean '
             'book, so break-even sits near <b>12%</b> and the lifetime figure '
             'is 14%. Scoped to books opened on or after '
             + STRAND_SINCE + ', when the current maker (MIN_OPEN_H=6, 5 books) '
             'took effect &mdash; earlier books measure a maker that no longer '
             'exists. The n beside the rate is the whole caveat at this sample '
             'size.</div>')
    h.append('<div class="note">'
             # NO local paths here: publish_dashboard.py refuses to push a page
             # containing one, and naming the kill file by its full path silently
             # blocked every publish for days.
             '<b>89%</b> optimistic ceiling. Kill switch: create the '
             '<code>STOP_MAKER</code> file in the project root.</div>')
    h.append('</details>')

    # signals
    _hid = (f' <span class="dim">· {sigs_hidden} out-of-scope hidden</span>'
            if sigs_hidden else '')
    # Counted here rather than in the row loop: the header is written first.
    _zmap = ft.get('zgate_drop_by_date') or {}
    _zn = sum(1 for s in sigs
              if s['div'] in set(_zmap.get(s['date']) or ()))
    _zhdr = (f' <span class="dim">· {_zn} z-gate</span>') if _zn else ''
    h.append('<details class="sec" open><summary>Signals — today &amp; tomorrow '
             f'({len(sigs)}){_hid}{_zhdr}</summary>')
    # Equity ABOVE the table. The table is today and tomorrow; the curve is the
    # whole frozen run behind it, which is the context that makes a two-day
    # slice legible rather than a mood.
    # The dashed second line is the z-gate SHADOW (see the League z-gate panel).
    # It is labelled in-sample in the key because it is: the gate was chosen by
    # reading this very curve's league breakdown, so of course it sits above.
    # The label is what stops the gap being read as money left on the table.
    _zc = ft.get('xlow_curve_zgate') or None
    h.append(equity_svg(ft.get('xlow_curve') or [], 'xlow',
                        f'xlow paper equity — frozen {ft.get("freeze","?")} '
                        f'({len(ft.get("xlow_curve") or [])} bets, $'
                        f'{PAPER_NOTIONAL:,.0f}/bet)',
                        overlay=_zc,
                        keys=('primary — live rule',
                              'z-gate shadow — in-sample, not evidence')))
    # data-defaultsort sorts by Date on load. Column 0 even though it is .opt2
    # on a phone, so the order is chronological at every width.
    h.append('<div class="card"><table data-defaultsort="0">')
    # On a phone this keeps Match / Ask / CLV / Result -- the answer -- and
    # drops Date, Lg, Fair, the other venue's CLV and Real trade, which are the
    # supporting evidence. No Bet column: the backed team is highlighted inside
    # Match instead, which also kills the "HOME YES" line wrap.
    h.append('<tr><th>Date</th><th class="opt2">Lg</th><th>Match</th>'
             '<th>Ask / CLV</th><th class="opt">Fair</th>'
             '<th class="opt2">CLV other</th>'
             '<th class="opt">Real trade</th><th>Result</th></tr>')
    if not sigs:
        h.append('<tr><td colspan="12" class="dim">no signals</td></tr>')
    net_settled = 0.0
    n_settled = 0
    n_wins = 0
    settled_asks = []          # the ask IS the implied probability, so the
                               # mean of these is what the win rate is beating
    for s in sigs:
        traded = ' '.join(f'<span class="pill {"k" if t=="Kalshi" else "p"}">{t}</span>'
                          for t in s['traded']) or '<span class="dim">n/a</span>'
        # data-sort carries the P&L so the Result column sorts by RETURN.
        # Unsettled rows get no key, which parks them together rather than
        # pretending an open bet is worth zero.
        # ASK FIRST. It used to be resolved below, after the P&L block, which
        # forced that block to fall back on the LEDGER's dollar figure. The
        # ledger stake is whatever xlow happened to be sizing at the time --
        # 1% of bankroll for months, then a flat $1 -- so the Result column
        # drifted with the stake while the equity curve directly above it was
        # drawn at PAPER_NOTIONAL. Two dollar figures on one screen measuring
        # the same bets at different sizes.
        _cheap = s['cheap']
        _ask = s['ask_k'] if _cheap == 'Kalshi' else s['ask_p']
        if _ask is None:
            _ask = s['ask_k'] if s['ask_k'] is not None else s['ask_p']
        _tag = {'Kalshi': 'K', 'Poly': 'P'}.get(_cheap, '')

        sort_key = ''
        _settled_pnl = None
        if s['settled']:
            # SCORED AT NOTIONAL, not at the ledger stake. One unit per bet:
            # profit (1-ask)/ask on a win, -1 on a loss, times PAPER_NOTIONAL.
            # Identical arithmetic to forward_test_unique_book.stat(), so this
            # column, the equity curve and the paper KPI tile cannot disagree.
            # Falls back to the ledger figure only when there is no ask to
            # score against.
            _led = s['settled']['pnl']
            if _led is not None and _ask and 0 < _ask < 1:
                pnl = (((1 - _ask) / _ask) if _led > 0 else -1.0) * PAPER_NOTIONAL
            else:
                pnl = _led
            if pnl is not None:
                sort_key = f'{pnl:.4f}'
                net_settled += pnl
                n_settled += 1
                _settled_pnl = pnl
                if pnl > 0:
                    n_wins += 1
            # money only -- its sign and colour already say won or lost, so the
            # outcome letter was ~20px of a ~366px screen spent on a restatement
            _prov = ('<span class="prov" title="result read from the match '
                     'record; the ledger settles it at 04:00">&middot;</span>'
                     if s['settled'].get('provisional') else '')
            res = (money(pnl) + _prov if pnl is not None
                   else esc(s['settled']['ftr'] or '?') + _prov)
        else:
            res = '<span class="dim">open</span>'
        strat = ('' if s['strategy'] == 'xlow'
                 else f' <span class="pill">{esc(s["strategy"])}</span>')

        # ONE ask column, tagged with the venue. Two columns showed the SAME
        # bet twice -- the rule names one venue, and the other side was blank
        # on most rows anyway. "0.19 (K)" says everything "0.19 | —" did in a
        # third of the width, and makes the Cheap column redundant.
        # (_cheap/_ask/_tag are resolved above, before scoring.)
        if _settled_pnl is not None and _ask is not None:
            settled_asks.append(_ask)
        ask_txt = ('<span class="dim">—</span>' if _ask is None else
                   f'<b>{_ask:.2f}</b>'
                   + (f' <span class="dim">({_tag})</span>' if _tag else ''))
        # and one CLV -- the named venue's, since that is the bet that was made
        clv_txt = pct(s['clv_k'] if _cheap == 'Kalshi' else s['clv_p'])

        fair_txt = '—' if s['fair'] is None else f'{s["fair"]:.3f}'
        _km = ft.get('ko_map') or {}
        s_ko = (_km.get((s['date'], s['div'], s.get('home'), s.get('away')))
                or _km.get((s['date'], s['div'], s.get('home'))))
        ko_txt = f' <span class="dim kotime">{s_ko:%H:%M}</span>' if s_ko else ''
        # Highlight the backed team inside the match. `bet` reads "HOME YES" /
        # "AWAY YES"; the " YES" is constant and was wrapping the cell to two
        # lines for no information.
        # z-GATE SHADOW MARK. The league's state as of THIS ROW'S date, not as
        # of now -- see zgate_drop_by_date. The row is NOT removed and nothing
        # about it changes: it is still a live signal, still scored, still in
        # the frozen forward test. Dropping it from the table would quietly
        # disagree with the number the test is accruing.
        _zdrop = set((ft.get('zgate_drop_by_date') or {}).get(s['date']) or ())
        _zpill = ''
        if s['div'] in _zdrop:
            _zpill = (' <span class="pill z" title="z-gate shadow: this league '
                      'is below the floor, so the shadow rule would sit this '
                      'one out. Not applied -- the bet still counts.">'
                      'z-gate</span>')
        hm, aw = esc(s.get('home') or '?'), esc(s.get('away') or '?')
        _side = str(s.get('bet') or '').upper()
        if _side.startswith('HOME'):
            match = f'<span class="pick">{hm}</span> vs {aw}'
        elif _side.startswith('AWAY'):
            match = f'{hm} vs <span class="pick">{aw}</span>'
        else:
            match = esc(s['match'])
        h.append(f'<tr{" class=zdrop" if _zpill else ""}><td class="dim" '
                 f'data-sort="{date_sort_key(s["date"], s_ko)}">'
                 f'{esc(s["date"][5:])}{ko_txt}</td>'
                 f'<td class="opt2">{esc(s["div"])}</td>'
                 f'<td class="mcell"><span class="mw">{match}{strat}{_zpill}</span></td>'
                 f'<td data-sort="{_ask if _ask is not None else ""}">{ask_txt}'
                 f'<span class="clvsub">{clv_txt}</span></td>'
                 f'<td class="dim opt">{fair_txt}</td>'
                 f'<td class="opt2">{pct(s["clv_k"] if _cheap != "Kalshi" else s["clv_p"])}</td>'
                 f'<td class="opt">{traded}</td>'
                 f'<td data-sort="{sort_key}">{res}</td></tr>')
    if n_settled:
        # nosort so the total stays pinned at the bottom under any sort order
        ncls = 'good' if net_settled > 0 else ('bad' if net_settled < 0 else 'dim')
        # ONE CELL PER COLUMN with the headers' own .opt/.opt2 classes. The old
        # colspan of 10 was correct only on a wide screen: below 900px five of
        # these columns are hidden, so it claimed columns that no longer existed
        # and pushed the total out of line with the Result figures it totals.
        # Columns: Date | Lg(opt2) | Match | Ask | Fair(opt) | CLV |
        # CLV other(opt2) | Real(opt) | Result = 9
        # WIN RATE AGAINST IMPLIED, not on its own. A bare win rate says
        # nothing here: these bets are placed at very different prices, so 30%
        # is excellent at an ask of 0.20 and dreadful at 0.45. The mean ask IS
        # the implied probability, so the gap between them is the only version
        # of "are we winning" that survives the price spread -- the same
        # framing the forward-test panel and the KPI tiles already use.
        wr = 100.0 * n_wins / n_settled
        imp = 100.0 * (sum(settled_asks) / len(settled_asks)) if settled_asks else None
        if imp is None:
            wr_txt = f' &middot; WR <b>{wr:.1f}%</b> ({n_wins}W)'
        else:
            edge = wr - imp
            ecls = 'good' if edge > 0 else ('bad' if edge < 0 else 'dim')
            wr_txt = (f' &middot; WR <b>{wr:.1f}%</b> ({n_wins}W) vs '
                      f'{imp:.1f}% implied '
                      f'<span class="{ecls}">{edge:+.1f}pp</span>')
        h.append('<tr class="nosort">'
                 f'<td class="dim"></td><td class="opt2"></td>'
                 f'<td class="dim"><b>Total</b> — {n_settled} settled of '
                 f'{len(sigs)}{wr_txt}</td>'
                 '<td></td><td class="opt"></td>'
                 '<td class="opt2"></td><td class="opt"></td>'
                 f'<td class="{ncls}"><b>{money(net_settled)}</b></td></tr>')
    h.append('</table></div>')
    h.append(f'<div class="note"><b>Result</b> is scored at the paper notional '
             f'({money(PAPER_NOTIONAL)}/bet), not at whatever the ledger '
             'happened to stake &mdash; so it matches the equity curve above '
             'and the paper KPI tile exactly. A win pays (1&minus;ask)/ask '
             'units, a loss costs one.</div>')
    h.append('<div class="note"><b>Ask</b> = the quoted price on each venue at signal '
             'time; the named (cheaper) venue is bold — that is what the bet costs. '
             '<b>Fair</b> = devigged Pinnacle probability. Two signals at the same CLV '
             'are very different bets at 0.14 and 0.47, and price is what drives the '
             'variance that makes this rule slow to measure.</div>')
    _zany = sorted({lg for v in (ft.get('zgate_drop_by_date') or {}).values()
                    for lg in v})
    if _zany:
        h.append('<div class="note"><span class="pill z">z-gate</span> marks a '
                 'signal in a league the <b>shadow</b> exclusion rule currently '
                 'sits out &mdash; below the floor on a point-in-time read of '
                 'its own history, judged on results known BEFORE that row\'s '
                 'date. Currently: <b>' + esc(', '.join(_zany)) + '</b>.<br>'
                 'These rows are <b>not removed and not altered</b>. The signal '
                 'fired, the bet is scored, and it still counts in the frozen '
                 'forward test exactly as before &mdash; the mark only says '
                 'what a rule that is not switched on would have done. Hiding '
                 'them would put this table in disagreement with the number the '
                 'test is accruing. See the League z-gate panel.</div>')

    h.append('</details>')

    # XHIGH PANEL REMOVED 2026-08-30 (user): "it's clearly not a good
    # strategy". The rule still fires into the forward test and its frozen row
    # below, so the evidence is not lost -- only the today/tomorrow board and
    # its equity curve are gone from the page.

    # positions
    h.append(f'<details class="sec" open><summary>Open positions ({len(k_pos)+len(p_pos)})</summary><div class="card"><table>')
    h.append('<tr><th>Venue</th><th>Market</th><th>Qty</th><th>Cost</th><th>Value</th><th>Fees</th></tr>')
    # GROUPED BY GAME (2026-08-20, user request). The maker holds one leg per
    # outcome, so an ungrouped list interleaves the three legs of a match with
    # everything else and you cannot see whether a book is complete. Grouping
    # puts a match on one header row with its subtotal, legs indented beneath.
    # A 3-leg group whose costs sum near $1.00 per contract is a complete book;
    # a 1- or 2-leg group is a stranded one, which is the state worth spotting.
    # MAKER BOOKS vs XLOW BETS ARE NOT THE SAME OBJECT (2026-08-23, user).
    # This panel was written for the maker, which holds one SHORT leg per
    # outcome, so it framed every group as "N of 3" of a book. An xlow entry is
    # a single directional BUY -- there is no book and no second leg coming --
    # and rendering it as "1 of 3" in red says a complete position is stranded.
    #
    # The discriminator is structural, not a lookup: the maker only ever posts
    # asks, so its legs are SHORT (position_fp negative); xlow buys the ask, so
    # its legs are LONG. That holds without consulting the ledger, which matters
    # because this panel reads the exchange and must stay right even when the
    # local ledger is behind.
    def _qf(x):
        try:
            return float(x.get('qty'))
        except (TypeError, ValueError):
            return 0.0

    _arms = arm_labels()
    # ONE SECTION PER STRATEGY, BOTH VENUES INSIDE (2026-09-01, user). The
    # first cut of this split nested venue-outer/strategy-inner, which gave
    # "Kalshi - market maker" and "Kalshi - xlow" and would have grown to four
    # bands once Poly-US holds anything. The strategy is the thing being
    # compared; the venue is already on every row as a pill, so it does not
    # need to be in the band too.
    #
    # Flattened to (venue, pill, game, legs) so one pass can group by strategy
    # across both books. Within a section, Kalshi sorts ahead of Poly-US and
    # then by market, so the venue stays visually clustered without owning a
    # heading.
    #
    # SPLIT PER LEG, NOT PER EVENT. Grouping by event first and then asking
    # "are ALL these legs long?" gets a MIXED event wrong in both directions:
    # 2026-09-01 BCSOU held BC +2.00 (an xlow buy) and SOU -3.00 (a maker
    # short), and the whole event was filed under the maker and rendered
    # "2 of 3" -- which claimed the maker held two legs when it held one, hid
    # an xlow bet inside the maker section, and mis-stated both section counts.
    #
    # The strategy is a property of the LEG, so the key is (event, is_long).
    # A mixed event now appears once in each section: SOU as a maker book at
    # 1 of 3, BC as an xlow bet. That is two rows for one match, and it is
    # correct -- they are two unrelated positions that happen to share a game.
    _all = []
    for label, rows in (('Kalshi', k_pos), ('Poly-US', p_pos)):
        _g = defaultdict(list)
        for x in rows:
            _g[(x.get('game') or x['ref'], _qf(x) > 0)].append(x)
        _pill = 'k' if label == 'Kalshi' else 'p'
        for (game, _is_xl), legs in _g.items():
            _all.append((label, _pill, game, legs, _is_xl))

    # Same structural discriminator as the row tag below, not a ledger lookup:
    # the maker only ever posts asks so its legs are SHORT, xlow buys so its
    # legs are LONG. Correct even when the local ledger is behind the exchange.
    for _sect, _what, _want_xlow in (('market maker', 'book', False),
                                     ('xlow', 'bet', True)):
        _sel = sorted((t for t in _all if t[4] == _want_xlow),
                      key=lambda t: (t[0] != 'Kalshi', t[2]))
        if not _sel:
            continue
        _n = len(_sel)
        h.append('<tr class="nosort grouphdr"><td colspan="6">'
                 f'{esc(_sect)} '
                 f'<span class="dim">({_n} {_what}{"s" if _n != 1 else ""})'
                 '</span></td></tr>')
        for label, pill, game, legs, is_xlow in _sel:
            g_cost = sum(l['cost'] for l in legs)
            g_fees = sum(l['fees'] for l in legs)
            vals = [l['value'] for l in legs if l['value'] is not None]
            g_val = money(sum(vals)) if vals else '<span class="dim">—</span>'
            lg, teams = pretty_event(game)
            title = f'{lg} {teams}'.strip() if teams else game
            n = len(legs)
            if is_xlow:
                # A directional bet. No book framing, no "of 3".
                tag = '<span class="pill">xlow</span>'
                unit = f'{n} bet{"s" if n != 1 else ""}'
            else:
                # 3 legs = the book completed; fewer = stranded, and that is the
                # thing worth seeing at a glance, so it is called out here.
                tag = ('<span class="good">complete</span>' if n >= 3
                       else f'<span class="bad">{n} of 3</span>')
                unit = f'{n} leg{"s" if n != 1 else ""}'
            # WHICH PRICING ARM OWNS THIS BOOK. The three arms differ only in
            # what a part-filled book asks of its surviving legs, so without the
            # label a stranded book on the screen says nothing about which rule
            # produced it -- which is the whole point of running them in
            # parallel. xlow entries have no arm and get nothing.
            _arm = '' if is_xlow else _arms.get(game, '')
            _arm_html = (f'<br><span class="pill" title="pricing arm">{esc(_arm)}</span>'
                         if _arm else '')
            h.append(f'<tr><td><span class="pill {pill}">{label}</span>{_arm_html}</td>'
                     f'<td><b>{esc(title)}</b> {tag}</td>'
                     f'<td class="dim">{unit}</td>'
                     f'<td><b>{money(g_cost)}</b></td><td>{g_val}</td>'
                     f'<td class="dim">${g_fees:.3f}</td></tr>')
            for x in legs:
                val = (money(x['value']) if x['value'] is not None
                       else '<span class="dim">—</span>')
                leg = x.get('leg') or x['ref']
                h.append(f'<tr class="nosort"><td></td>'
                         f'<td class="dim" style="padding-left:18px">↳ {esc(leg)}</td>'
                         f'<td class="dim">{esc(x["qty"])}</td>'
                         f'<td>{money(x["cost"])}</td><td>{val}</td>'
                         f'<td class="dim">${x["fees"]:.3f}</td></tr>')
    if not (k_pos or p_pos):
        h.append('<tr><td colspan="6" class="dim">none open</td></tr>')
    h.append('</table></div>')
    # SUBACCOUNTS. Rendered only when more than one exists -- see subaccounts().
    # This is the ONLY place a non-primary subaccount is visible: the Kalshi app
    # shows the primary alone (its Portfolio arrow toggles deposit/withdraw, it
    # is not an account switcher), so cash or positions parked elsewhere do not
    # appear on the phone at all.
    if subs:
        h.append('<div class="card" style="margin-top:10px"><table>')
        h.append('<tr><th>Subaccount</th><th>Cash</th><th>Long</th>'
                 '<th>Short</th><th>Resting</th></tr>')
        _tot = 0.0
        for r in subs:
            if r.get('n') is None:
                h.append(f'<tr><td colspan="5" class="bad">subaccounts '
                         f'unreadable: {esc(r.get("error") or "")}</td></tr>')
                continue
            _tot += r['cash']
            name = 'primary' if r['n'] == 0 else f'subaccount {r["n"]}'
            # long = bought (xlow's shape), short = sold (the maker's shape) --
            # the same discriminator the positions table above uses.
            h.append(f'<tr><td><b>{esc(name)}</b> '
                     f'<span class="dim">#{r["n"]}</span></td>'
                     f'<td>{money(r["cash"])}</td>'
                     f'<td>{r["long"]}</td><td>{r["short"]}</td>'
                     f'<td class="dim">{r["resting"]}</td></tr>')
        h.append(f'<tr class="nosort"><td class="dim">total cash</td>'
                 f'<td><b>{money(_tot)}</b></td>'
                 f'<td colspan="3" class="dim">across all subaccounts</td></tr>')
        h.append('</table></div>')
        h.append('<div class="note">The Kalshi app shows the <b>primary</b> '
                 'subaccount only, so anything held outside it is invisible '
                 'there &mdash; this table is the only place it shows up.</div>')
    if p_cost:
        h.append(f'<div class="note">Poly-US marked value {money(p_val)} vs cost {money(p_cost)} '
                 f'({pct((p_val/p_cost-1)*100)}) — note thin books mark to a low bid, '
                 f'which overstates losses.</div>')
    for vn in ('kalshi', 'polyus'):
        if v[vn]['error']:
            h.append(f'<div class="note bad">{vn} API error: {esc(v[vn]["error"])}</div>')

    # forward test
    h.append('</details>')
    h.append(f'<details class="sec" open><summary>Forward test (out-of-sample)</summary><div class="card"><table>')
    h.append('<tr><th>Rule</th><th>n</th><th>WR</th><th>Implied</th><th>ROI/bet</th><th>z</th></tr>')
    if ft['error']:
        h.append(f'<tr><td colspan="6" class="bad">{esc(ft["error"])}</td></tr>')
    for name, s in ft['rows'].items():
        if not s:
            h.append(f'<tr><td>{esc(name)}</td><td colspan="5" class="dim">—</td></tr>')
            continue
        h.append(f'<tr><td>{esc(name)}</td><td>{s["n"]}</td><td>{s["wr"]:.1f}%</td>'
                 f'<td class="dim">{s["iwr"]:.1f}%</td><td>{pct(s["roi"])}</td>'
                 f'<td class="dim">{s["z"]:+.2f}</td></tr>')
    h.append('</table></div>')
    h.append('<div class="note"><b>Scope.</b> The primary row is '
             f'{len(ft.get("bt_leagues") or [])} leagues: the 20 the backtest '
             'was measured on &mdash; a universe fixed before this test '
             'started &mdash; <b>minus BSA and CL1</b>, plus NWSL. Leagues '
             'outside it are still scored, separately: the pooled and '
             'out-of-scope summary rows are no longer shown here, but both '
             'series are still written daily to '
             '<code>forward_test_unique_book_log.csv</code>, and every '
             'out-of-scope league still appears in the by-league table '
             'below.<br>'
             '<b>PL1 and USL removed 2026-08-27.</b> Neither has any '
             'Polymarket.com market, so the rule could never fire on them '
             'directly &mdash; they entered only through a <b>Poly-US</b> '
             'substitution the n=286 backtest never used, and that slice ran '
             'flat (+0.8% on n=71) while the Poly.com-confirmed legs ran '
             '+34.5%. They keep scoring in the by-league table, the daily '
             'CSV and the poly-US row, so nothing stops being measured; it '
             'just stops counting toward the frozen rule.</div>')
    h.append('<div class="note bad"><b>Read the primary with care.</b> BSA and '
             'CL1 were removed on 2026-08-23 <i>after</i> they showed as the '
             'two worst leagues in the scope (&minus;40.2% on n=35 and '
             '&minus;75.2% on n=13). That is post-hoc selection, unlike the '
             'original backtest scope, which was fixed in advance. It moves '
             'the headline from +18.75% (z+1.37) to +34.46% (z+2.55) and '
             'nearly all of that is the two removals &mdash; so the z crossing '
             '1.96 is <b>manufactured by the choice, not earned by the rule</b>. '
             'Everything before 2026-08-23 is in-sample for this scope; only '
             'bets struck after it test it.</div>')
    h.append('<div class="note">unique/overlap are diagnostics, not traded: the rule bets '
             '"unique" shapes and skips "overlap" ones, so a wide spread between them means '
             'the gate is doing real work.</div>')

    # per league forward
    if ft['leagues']:
        h.append('</details>')
        h.append(f'<details class="sec" open><summary>Forward by league</summary><div class="card"><table>')
        # z is on WIN RATE vs implied (wins - sum p) / sqrt(sum p(1-p)), not on
        # ROI -- the two answer different questions and the WR one always looks
        # kinder. Shown dim because at these per-league sample sizes it is a
        # diagnostic, not a verdict.
        h.append('<tr><th>Lg</th><th>Scope</th><th>n</th><th>WR</th>'
                 '<th>Implied</th><th>ROI/bet</th><th>z</th></tr>')
        # LIVE MEANS "WE BET REAL MONEY HERE", not "in the frozen scope"
        # (2026-09-07). bt_leagues is PRIMARY_LEAGUES, the MEASUREMENT scope,
        # and since CH1 was enabled on 2026-09-05 that is no longer the same
        # set as what trades: CH1 bets through the GAP path while being kept
        # out of the frozen primary so the headline stays comparable. It was
        # still being labelled "track" while placing real orders.
        #
        # Same union as scope_signals, and the same failure mode it fixed --
        # anything reading PRIMARY_LEAGUES to mean "what we trade" is now
        # wrong. Falls back to the measurement scope if daily_signals cannot
        # be read.
        _scope = set(ft.get('bt_leagues') or [])
        try:
            from daily_signals import AUTO_TRADE_LEAGUES, AUTO_TRADE_EXCLUDE_LEAGUES
            _scope |= (set(AUTO_TRADE_LEAGUES) - set(AUTO_TRADE_EXCLUDE_LEAGUES))
        except Exception:
            pass
        for lg, s in ft['leagues'].items():
            if not s:
                continue
            live = lg in _scope
            tag = ('<span class="good">live</span>' if live
                   else '<span class="dim">track</span>')
            h.append(f'<tr><td>{esc(lg)}</td><td>{tag}</td>'
                     f'<td>{s["n"]}</td><td>{s["wr"]:.1f}%</td>'
                     f'<td class="dim">{s["iwr"]:.1f}%</td><td>{pct(s["roi"])}</td>'
                     f'<td class="dim">{s["z"]:+.2f}</td></tr>')
        if ft.get('gap_all'):
            g = ft['gap_all']
            # The gap leagues (USL/PL1/CH1) now appear in the table ABOVE, tagged
            # by scope, because fire_xlow_primary produces them. Their per-league
            # rows used to be repeated down here as a separate rule; since
            # 2026-08-23 that is a duplicate AND the old rows carried 6 columns
            # against this table's 7, so only the TOTAL survives -- as a
            # decomposition of the poly-US-confirmed slice, not as extra bets.
            # nosort keeps it pinned at the bottom instead of sorting in as a
            # league.
            h.append(f'<tr class="nosort"><td class="dim">all poly-US confirm *</td>'
                     f'<td class="dim">&mdash;</td>'
                     f'<td class="dim">{g["n"]}</td>'
                     f'<td class="dim">{g["wr"]:.1f}%</td>'
                     f'<td class="dim">{g["iwr"]:.1f}%</td>'
                     f'<td class="dim">{pct(g["roi"])}</td>'
                     f'<td class="dim">{g["z"]:+.2f}</td></tr>')
        h.append('</table></div>')
        h.append('<div class="note">at these sample sizes every cell is within ~1 game of '
                 'its implied count — league-level reads are noise, not signal.</div>')
        if ft.get('gap_all'):
            h.append('<div class="note"><b>Scope</b> column: <span class="good">live</span> '
                     '= armed for real money at $1; <span class="dim">track</span> = signals '
                     'and forward test only, no orders.<br>'
                     '* <b>poly-US confirm</b> = USL, PL1 and CH1, which have no Polymarket.com '
                     'market at all, so the same strict rule uses Polymarket-US as the '
                     'confirming book. Those leagues are listed above like any other and their '
                     'total is repeated here so the mix can be decomposed. The n=286 +24.7% '
                     'backtest used Poly.com as the confirming book — whether Poly-US confirms '
                     'Pinnacle as well is <b>untested</b>.<br>'
                     'Of those three only <b>CH1</b> is armed (2026-09-05): it is the one with '
                     'a verified Poly-US market, and it trades WITHOUT being in the frozen '
                     'primary, so its bets stay in the poly-US row and never move the headline. '
                     'USL and PL1 stay on paper.</div>')

    # XHIGH PANEL AND xhigh-BY-LEAGUE REMOVED 2026-09-01 (user: "we can keep
    # tracking it, but it doesn't need to be in the dashboard"). The rule is
    # still defined and still logged by the forward test every run, so nothing
    # stops accruing -- it just has no surface on this page. This lone closing
    # tag is the one that closed the section ABOVE, not the xhigh panel; keep
    # it or the page silently collapses.
    h.append('</details>')

    # ── LEAGUE z-GATE (SHADOW) ───────────────────────────────────────────
    # Renders three things that must never be added together: what the gate
    # would do to the NEXT fixture, what it would have done over the history it
    # was discovered on, and what it has done since its own freeze. Only the
    # last is evidence, and it is empty by design until ZGATE_FREEZE passes.
    # Nothing on this panel gates an order -- see forward_test_unique_book's
    # ZGATE block for why the SELECT direction was rejected and this one kept.
    zg = ft.get('zgate')
    if zg:
        _dropped = sorted([(lg, s) for lg, s in zg['state'].items() if s['dropped']],
                          key=lambda kv: kv[1]['z'])
        _ok = sorted([(lg, s) for lg, s in zg['state'].items()
                      if not s['dropped'] and not s.get('warm')],
                     key=lambda kv: kv[1]['z'])

        def _zrow(label, s, cls='', note=''):
            if not s:
                return (f'<tr class="{cls}"><td>{label}</td>'
                        '<td colspan="4" class="dim">nothing settled yet</td></tr>')
            return (f'<tr class="{cls}"><td>{label}</td><td>{s["n"]}</td>'
                    f'<td>{s["wr"]:.1f}%</td><td>{pct(s["roi"])}</td>'
                    f'<td class="dim">{s["z"]:+.2f}</td></tr>{note}')

        h.append('<details class="sec"><summary>League z-gate '
                 f'<span class="count">(shadow · {len(_dropped)} dropped)</span>'
                 '</summary>')
        # 1. current state -- the actionable half
        h.append('<div class="card"><table>')
        h.append('<tr><th>Lg</th><th>State</th><th>n</th><th>ROI/bet</th><th>z</th></tr>')
        for lg, s in _dropped:
            h.append(f'<tr><td>{esc(lg)}</td><td><span class="bad">dropped</span></td>'
                     f'<td>{s["n"]}</td><td>{pct(s["roi"])}</td>'
                     f'<td class="bad">{s["z"]:+.2f}</td></tr>')
        for lg, s in _ok:
            h.append(f'<tr><td>{esc(lg)}</td><td><span class="dim">ok</span></td>'
                     f'<td class="dim">{s["n"]}</td><td class="dim">{pct(s["roi"])}</td>'
                     f'<td class="dim">{s["z"]:+.2f}</td></tr>')
        if not _dropped and not _ok:
            h.append('<tr><td colspan="5" class="dim">no league past warmup yet</td></tr>')
        h.append('</table></div>')
        h.append(f'<div class="note">A league is <b>dropped</b> once it has '
                 f'{zg["min_n"]}+ settled bets and its z falls below '
                 f'{zg["floor"]:+.1f}. State is point-in-time: a dropped league '
                 'that recovers above the floor comes back on its own, and the '
                 'bets it lost while out are not replayed.<br>'
                 '<b>Primary scope only.</b> The gate runs inside the frozen '
                 'scope, so the track-only leagues listed in the table above '
                 '(BSA, CO1, JP1, CN1 &hellip;) are absent here by design &mdash; '
                 'several are far worse than anything on this list and are '
                 'already outside the headline.</div>')

        # 2. the record -- evidence first, discovery second and dimmed
        h.append('<div class="card"><table>')
        h.append('<tr><th>Since freeze</th><th>n</th><th>WR</th>'
                 '<th>ROI/bet</th><th>z</th></tr>')
        h.append(_zrow('gated (shadow)', zg['post_on']))
        h.append(_zrow('ungated (control)', zg['post_off']))
        h.append('</table></div>')
        h.append('<div class="card"><table>')
        h.append('<tr><th class="dim">Before freeze &mdash; in-sample</th>'
                 '<th class="dim">n</th><th class="dim">WR</th>'
                 '<th class="dim">ROI/bet</th><th class="dim">z</th></tr>')
        h.append(_zrow('gated', zg['disc_on'], cls='dim'))
        h.append(_zrow('ungated', zg['disc_off'], cls='dim'))
        h.append(_zrow('the dropped bets', zg['disc_drop'], cls='dim'))
        h.append('</table></div>')

        h.append('<div class="note bad"><b>Discovery, not validation.</b> This '
                 'gate was found by reading the by-league table above &mdash; '
                 'the same sample the "before freeze" rows score &mdash; so '
                 'those rows are <b>guaranteed</b> to flatter it and are not '
                 'evidence of anything. That is the identical error to the '
                 'BSA/CL1 scope edit, one level deeper. Only the '
                 f'<b>since {esc(zg["freeze"])}</b> rows test it, and they '
                 'start empty.</div>')
        h.append('<div class="note"><b>Why exclude and not select.</b> The '
                 'question asked was the opposite one &mdash; bet only leagues '
                 'with z above a threshold. Walk-forward that fails: inside the '
                 'primary scope it returned +8.9% on the bets it took against '
                 '+17.3% on the bets it skipped, and a random draw of the same '
                 'size beat it two thirds of the time. Selecting winners needs '
                 'the top tail to persist and costs ~80% of the sample; '
                 'excluding losers needs only the bottom tail and costs ~5%. '
                 'League-level persistence is real but weak (first-half z vs '
                 'second-half ROI r=+0.35, p=0.03 over 29 leagues) &mdash; '
                 'enough to find the worst, not to rank the best.</div>')
        h.append('<div class="note"><b>Shadow only.</b> Nothing here gates an '
                 'order or changes the frozen primary, which stays unfiltered '
                 'above. Both series are written daily to '
                 '<code>forward_test_unique_book_log.csv</code> as '
                 '<code>xlow_zgate_*</code> and its control '
                 '<code>xlow_postz_*</code>.</div>')
        h.append('</details>')

    # market maker
    # ── xlow — LAST 24h ───────────────────────────────────────────────────
    # Replaced the Live fills panel 2026-08-23 (user request). Live fills
    # measured the MAKER -- queue position and time to fill, a venue-mechanics
    # question. This measures the frozen SIGNAL rule over a recent window,
    # which is a different question about a different book.
    #
    # live_fills() is deliberately left DEFINED and mm_live_fills.csv keeps
    # being written: the fill experiment is still running, it just no longer
    # owns a panel. Same reasoning as the paper-maker stub below.
    l24 = ft.get('last24') or {}
    _rules = [('xlow', l24.get('xlow') or {}, ft.get('freeze'))]
    n_set = sum(len(d.get('bets') or []) for _, d, _ in _rules)
    n_open = sum(len(d.get('open') or []) for _, d, _ in _rules)
    h.append('<details class="sec" open><summary>xlow — last 24h '
             f'<span class="count">({n_set} settled'
             + (f' · {n_open} open' if n_open else '') + ')</span></summary>')
    if ft.get('error'):
        h.append(f'<div class="note bad">forward test unreadable: '
                 f'{esc(ft["error"])}</div>')
    h.append('<div class="kpis">')
    for name, d, since in _rules:
        s = d.get('stat')
        w, l = d.get('w', 0), d.get('l', 0)
        n_op = len(d.get('open') or [])
        opn = (f' · {n_op} open' if n_op else '')
        if not s:
            h.append(f'<div class="kpi"><div class="lbl">{name} '
                     f'<span class="dim">(24h, paper)</span></div>'
                     f'<div class="val dim">—</div>'
                     f'<div class="lbl">no settled bets{opn}</div></div>')
            continue
        # Same arithmetic as the all-time paper tiles: one unit per bet, times
        # PAPER_NOTIONAL. Deliberately NOT a bankroll here -- a 24h slice is a
        # delta, and showing it against the opening balance would invite reading
        # one day's noise as the book's position.
        net = s['roi'] / 100.0 * s['n'] * PAPER_NOTIONAL
        # COLOUR BY MONEY, NOT BY RECORD. These rules bet longshots -- implied
        # WR sits around 22-29% -- so a losing record is the NORMAL shape of a
        # winning day: 10-19 on this window is +75.7% ROI. Keying the colour to
        # w>l painted that red, which is precisely the win-rate-over-net error
        # the edge-mine work concluded against (project_edge_mine_exhausted).
        # The record stays as the headline number because it is what was asked
        # for; only the colour follows the P&L.
        cls = 'good' if net > 0 else ('bad' if net < 0 else '')
        h.append(f'<div class="kpi"><div class="lbl">{name} '
                 f'<span class="dim">(24h, paper)</span></div>'
                 f'<div class="val {cls}">{w}&ndash;{l}</div>'
                 f'<div class="lbl">{money(net)} · {pct(s["roi"])}/bet</div>'
                 f'<div class="lbl">WR {s["wr"]:.0f}% vs {s["iwr"]:.0f}% '
                 f'implied{opn}</div>'
                 f'<div class="lbl dim">frozen {esc(str(since or "?"))}</div></div>')
    h.append('</div>')
    # Sorted by kickoff. Still driven off `_rules` rather than the one dict
    # inlined, so re-adding a rule here stays a one-line change.
    rows24 = [(name, b, False) for name, d, _ in _rules
              for b in (d.get('bets') or [])]
    rows24 += [(name, b, True) for name, d, _ in _rules
               for b in (d.get('open') or [])]
    rows24.sort(key=lambda t: (t[1].get('ko') or datetime.min), reverse=True)
    if rows24:
        h.append('<div class="card"><table>')
        h.append('<tr><th>KO</th><th>Rule</th><th class="opt2">Lg</th>'
                 '<th>Match</th><th>Side</th><th>Price</th>'
                 '<th class="opt">CLV</th><th>Result</th></tr>')
        for name, b, is_open in rows24:
            ko = b.get('ko')
            when = ko.strftime('%m-%d %H:%M') if ko else '—'
            match = f'{b.get("home") or "?"} v {b.get("away") or "?"}'
            px = b.get('px')
            pxs = f'{px * 100:.0f}c' if px else '—'
            clv = b.get('clv')
            clvs = f'{clv * 100:.1f}%' if clv is not None else '—'
            if is_open:
                # An unsettled fixture reads won=False. Never render that as a
                # loss -- see fire_xlow_primary's docstring.
                res = '<span class="dim">open</span>'
            elif b['won']:
                res = '<span class="good">W</span>'
            else:
                res = '<span class="bad">L</span>'
            h.append(f'<tr><td class="dim">{esc(when)}</td>'
                     f'<td>{esc(name)}</td>'
                     f'<td class="dim opt2">{esc(b.get("league") or "")}</td>'
                     f'<td>{esc(match)}</td>'
                     f'<td>{esc(b.get("side") or "")}</td>'
                     f'<td><b>{pxs}</b></td>'
                     f'<td class="dim opt">{clvs}</td>'
                     f'<td>{res}</td></tr>')
        h.append('</table></div>')
    else:
        h.append('<div class="card"><div class="note">'
                 'xlow did not fire on a fixture that kicked off in the last '
                 '24 hours</div></div>')
    h.append('<div class="note"><b>All leagues, unlike the headline.</b> This '
             'panel is a recent-activity view, so it covers everything being '
             'tracked &mdash; including the leagues added for collection, which '
             'the primary forward number excludes. It will not tie out to the '
             'Fwd ROI tile.</div>')
    h.append('<div class="note"><b>Paper.</b> The real xlow ledger is the '
             'signals panel above. Prices are the ask the rule would have '
             'taken, so these are pre-fee.<br>'
             'A day is <b>noise</b> at this per-bet variance &mdash; the frozen '
             'decision point is n=1000, not a good afternoon.</div>')
    h.append('</details>')

    # PAPER MAKER PANELS: DISABLED, NOT DELETED (2026-08-20).
    # Everything from here to the end of this block is the old paper
    # market-maker rendering. It is switched off by this stub rather than cut
    # out because it is ~330 lines of nested <details> and deleting it by hand
    # is how you get an unbalanced tag that only shows up as a silently
    # collapsed page. maker_state()/gate_state() are still defined and still
    # usable from research. If it is still off in a month, delete it properly
    # and check the tag balance with the same count the build does.
    mk = {'books': 0}
    if mk['books']:
        rate = 100.0 * mk['complete'] / mk['books']
        h.append('<details class="sec" open><summary>Market maker '
                 f'<span class="count">({mk["books"]} paper books)</span></summary>')

        # THE PANEL LEADS WITH ONE COMPARISON. Previously it opened with a
        # four-row legs-filled table and six sub-tables, which buried the only
        # decision in it. A complete book is near-deterministic and a partial one
        # is a coin flip, so the whole question is "does completion clear the rate
        # that pays for the partials" -- two numbers, side by side, at the top.
        be, lr = mk.get('breakeven'), mk.get('live_rate')
        if be is not None and lr is not None:
            ok = lr >= be
            h.append('<div class="kpis">')
            h.append(f'<div class="kpi"><div class="lbl">Completion</div>'
                     f'<div class="val {"good" if ok else "bad"}">{lr:.0f}%</div>'
                     f'<div class="lbl">{mk["comp"]["n"]} of {mk["risked"]} books '
                     'with a fill</div></div>')
            h.append(f'<div class="kpi"><div class="lbl">Break-even</div>'
                     f'<div class="val">{be:.0f}%</div>'
                     f'<div class="lbl">{lr-be:+.0f}pp margin</div></div>')
            g = mk.get('gated')
            if g:
                gok = g['rate'] >= be
                h.append(f'<div class="kpi"><div class="lbl">At live gate '
                         f'&ge;{mk["gate"]:,}</div>'
                         f'<div class="val {"good" if gok else "bad"}">'
                         f'{g["rate"]:.0f}%</div>'
                         f'<div class="lbl">{g["rate"]-be:+.0f}pp · n={g["n"]}</div></div>')
            h.append(f'<div class="kpi"><div class="lbl">Modelled per book</div>'
                     f'<div class="val {"good" if mk["ev_c"]>0 else "bad"}">'
                     f'{mk["ev_c"]:+.2f}c</div>'
                     + (f'<div class="lbl">{g["ev_c"]:+.2f}c at the gate</div>'
                        if g else '<div class="lbl">from the two rows below</div>')
                     + '</div>')
            h.append('</div>')
            p = min(100, int(lr / be * 100)) if be else 0
            h.append(f'<div class="note">completion vs break-even'
                     f'<div class="bar"><i style="width:{p}%"></i></div></div>')

        h.append('<div class="card"><table>')
        h.append('<tr><th>Outcome</th><th>Books</th><th class="opt2">Share</th>'
                 '<th>Per book</th><th>Spread (SD)</th></tr>')
        for key, lbl in (('comp', 'complete — all 3 legs sold'),
                         ('part', 'partial — stranded on 1 or 2')):
            s = mk.get(key)
            if not s:
                continue
            cls = 'good' if s['mean'] > 0 else ('bad' if s['mean'] < 0 else 'dim')
            h.append(f'<tr class="nosort"><td>{lbl}</td><td>{s["n"]}</td>'
                     f'<td class="dim opt2">{100*s["n"]/mk["risked"]:.0f}%</td>'
                     f'<td class="{cls}">{s["mean"]:+.2f}c</td>'
                     f'<td class="dim">&plusmn;{s["sd"]:.1f}c</td></tr>')
        nofill = mk.get('scored', 0) - mk.get('risked', 0)
        if nofill > 0:
            h.append(f'<tr class="nosort"><td class="dim">no fill — never at risk</td>'
                     f'<td class="dim">{nofill}</td><td class="dim opt2">—</td>'
                     '<td class="dim">0.00c</td><td class="dim">—</td></tr>')
        h.append('</table></div>')
        if mk.get('unscored'):
            h.append(f'<div class="note">{mk["unscored"]} older books are excluded '
                     'from this panel: they predate the volume column, so the gate '
                     'cannot be evaluated against them. Same rule as the sweep '
                     'below. They are all v1/v2, whose quoting rule sat above the '
                     'market&rsquo;s own ask on 86% of legs — pooling them in moves '
                     'break-even from 61% to 79% on retired behaviour.</div>')
        h.append('<div class="note">Read the <b>Spread</b> column, not the P&amp;L. '
                 'A complete book collects the overround whoever wins, so it lands '
                 'in a narrow band; a partial book is a naked position on one '
                 'outcome and swings ~25&times; wider for a mean that is not '
                 'distinguishable from zero. That is why completion rate is the '
                 'headline and total P&amp;L is not: the profit is a near-certainty '
                 'and all the variance is partial fills. Modelled per-book above is '
                 'completion&times;complete + (1&minus;completion)&times;partial — '
                 'both measured far more precisely than realised P&amp;L, which '
                 'would need ~841 books at the current gate to resolve.</div>')

        # per-bucket drill-down, now one collapsed block instead of four, with the
        # bucket as a column. Nested <details class="sub"> rather than .sec so the
        # page-level expand/collapse button does not fight with these.
        allbooks = sorted((b for bs in mk.get('detail', {}).values() for b in bs),
                          key=lambda b: (b['date'], -b['n']), reverse=True)
        # Capped at MM_BOOKS_MAX. Sorted newest-first, so the cap keeps the rows
        # anyone actually reads and drops the tail -- the aggregates above are
        # computed from ALL books regardless, so nothing here changes a number.
        shown_books = allbooks[:MM_BOOKS_MAX]
        if allbooks:
            more = len(allbooks) - len(shown_books)
            label = (f'{len(shown_books)} of {len(allbooks)}' if more
                     else f'{len(allbooks)}')
            h.append(f'<details class="sub"><summary>Recent books '
                     f'<span class="count">({label})</span></summary>')
            h.append('<div class="card"><table>')
            h.append('<tr><th>Date</th><th>Lg</th><th>Match</th><th>Filled</th>'
                     '<th class="opt">Legs</th>'
                     '<th>Min leg vol</th><th class="opt2">Gross @post</th>'
                     '<th class="opt">Regime</th><th>P&amp;L</th></tr>')
            for b in shown_books:
                pc = 'good' if b['pnl_c'] > 0 else ('bad' if b['pnl_c'] < 0 else 'dim')
                gr = '—' if b['gross_c'] is None else f'{b["gross_c"]:+.1f}c'
                if b['vol_min'] is None:
                    vol = '<span class="dim">—</span>'
                else:
                    # mark whether this book would clear the live gate, so the
                    # threshold can be judged against outcomes rather than taken
                    # on trust
                    passes = mk['gate'] is not None and b['vol_min'] >= mk['gate']
                    vol = (f'<span class="good">{b["vol_min"]:,.0f}</span>' if passes
                           else f'<span class="dim">{b["vol_min"]:,.0f}</span>')
                fl = f'{b["n"]}/3' if b['n'] else '—'
                fc = 'good' if b['n'] == 3 else ('bad' if b['n'] else 'dim')
                h.append(f'<tr><td class="dim">{esc(b["date"][5:])}</td>'
                         f'<td>{esc(b["div"])}</td><td>{esc(b["match"])}</td>'
                         f'<td class="{fc}">{fl}</td>'
                         f'<td class="dim opt">{esc(b["sides"])}</td>'
                         f'<td>{vol}</td>'
                         f'<td class="dim opt2">{gr}</td>'
                         f'<td class="dim opt">{esc(b["regime"])}</td>'
                         f'<td class="{pc}">{b["pnl_c"]:+.1f}c</td></tr>')
            h.append('</table></div></details>')
        h.append('<div class="note" style="border-left:3px solid var(--warn);'
                 'padding-left:9px"><b>These are CEILING numbers, not results.</b> '
                 'Fills here come from mm_settle, which counts any trade printed at '
                 'or above our ask as ours &mdash; it ignores queue position, and its '
                 'own docstring calls it a ceiling. The live quoter uses a stricter '
                 'queue-aware test, and the two disagree: the settler scores 59 books '
                 'as 2-of-3 while the quoter took no action on 39 of them, because it '
                 'never believed two legs had sold. Completion rates and the P&amp;L '
                 'split above are therefore upper bounds. The live view is now being '
                 'archived to <code>mm_live_fills.csv</code> so the gap can be '
                 'measured rather than guessed at.</div>')

        # LIVE GATE TRACKER. The gate rejects with a bare `continue`, so its cost
        # was previously invisible -- you could see what got quoted but never what
        # was turned away or by how much. mm_quoter now snapshots every book in
        # window each cycle; this renders it, so the threshold can be watched
        # against live books instead of only against the books it already let in.
        gs = gate_state()
        if gs:
            rows = gs['rows']
            ahead = gs.get('ahead') or []
            stale = gs['age_m'] is not None and gs['age_m'] > 5
            aheadn = (f' &middot; {len(ahead)} ahead' if ahead else '')
            h.append('<details class="sub" open><summary>Liquidity gate &mdash; live '
                     f'<span class="count">({gs["now_pass"]} of {len(rows)} books in '
                     f'window pass{aheadn})</span></summary>')
            h.append('<div class="card"><table>')
            h.append('<tr><th>To KO</th><th>Lg</th><th>Match</th>'
                     '<th>Min leg vol</th><th class="opt2">All three legs</th>'
                     '<th>Gate</th><th class="opt">Stage</th></tr>')
            if not rows:
                h.append('<tr><td colspan="7" class="dim">no books in the '
                         f'{gs["window_h"]}h window right now</td></tr>')
            # In-window books first, then everything still queued up behind them.
            # Both are rendered the same way; the Stage column is what separates
            # a book the gate has ruled on from one it has only looked at.
            for r in list(rows) + sorted(ahead, key=lambda x: x.get('hrs', 0)):
                ok = bool(r.get('pass'))
                # Truncate rather than round the displayed volume, and keep a
                # decimal on a near-miss shortfall. A book at 499.5 rounded to
                # "500" and printed next to "reject" reads as a broken gate --
                # seen on the tracker's first morning.
                short = gs['gate'] - r['minvol']
                gate = ('<span class="good">pass</span>' if ok else
                        '<span class="bad">&minus;'
                        + (f'{short:.1f}' if short < 10 else f'{short:,.0f}')
                        + '</span>')
                vols = ', '.join(f'{int(v):,}' for v in (r.get('vols') or []))
                # 'ahead' rows are not a verdict -- the gate has not ruled on
                # them, their volume is up to one sweep old, and it only goes
                # up. Label them with that age so a "pass" this far out is read
                # as a forecast rather than a decision.
                if r.get('stage') == 'ahead':
                    va = r.get('vol_age_m')
                    stage = ('<span class="dim">ahead of window'
                             + (f' &middot; vol {va:.0f}m old' if va else '')
                             + '</span>')
                else:
                    stage = {'quoted': '<span class="good">quoted</span>',
                             'gross_reject': '<span class="dim">thin overround</span>',
                             'gate': '<span class="dim">&mdash;</span>'}.get(
                                 r.get('stage'), esc(str(r.get('stage', ''))))
                h.append(f'<tr><td class="dim">{r["hrs"]:.1f}h</td>'
                         f'<td>{esc(r.get("div",""))}</td>'
                         f'<td>{esc(r.get("match",""))}</td>'
                         f'<td class="{"good" if ok else "dim"}">'
                         f'{int(r["minvol"]):,}</td>'
                         f'<td class="dim opt2">{vols}</td>'
                         f'<td>{gate}</td><td class="opt">{stage}</td></tr>')
            h.append('</table></div>')
            tot = gs['adm'] + gs['rej']
            share = f'{100.0*gs["adm"]/tot:.0f}%' if tot else '—'
            age = ('<span class="bad">snapshot '
                   f'{gs["age_m"]:.0f}m old — quoter may be down</span>' if stale
                   else f'snapshot {gs["age_m"]:.0f}m old' if gs['age_m'] is not None
                   else 'snapshot age unknown')
            h.append(f'<div class="note">gate <b>&ge; {gs["gate"]:,}</b> on the '
                     f'thinnest leg (regime {esc(str(gs["regime"]))}) · {age}<br>'
                     f'last {gs["days"]} day(s): <b>{gs["adm"]}</b> distinct books '
                     f'admitted, <b>{gs["rej"]}</b> turned away — the gate is letting '
                     f'through <b>{share}</b> of what it sees'
                     + (f' · today {gs["today_adm"]} / {gs["today_adm"]+gs["today_rej"]}'
                        if gs['today'] else '') + '</div>')
            h.append('<div class="note">The reject column shows how far <i>short</i> '
                     'a book fell, so a gate set too high shows up as a column of '
                     'near-misses rather than as nothing at all. Books are counted '
                     'once per day, and one that later trades enough volume to clear '
                     'the gate stops counting as rejected.</div>')
            if ahead:
                sw = gs.get('sweep_secs')
                h.append('<div class="note">Rows marked <b>ahead of window</b> are '
                         'not decisions. The gate has not ruled on them and they are '
                         'not in the admitted/turned-away counts above &mdash; they '
                         'are there so you can see what is coming rather than only '
                         f'what is in the {gs["window_h"]}h window. Their volume is '
                         'refreshed'
                         + (f' every {sw//60:.0f} min' if sw else ' on a slow sweep')
                         + ', not every cycle, because each one costs Kalshi calls. '
                         'Volume only rises, so a book short of the gate this far out '
                         'can still clear it by kickoff &mdash; read a <b>pass</b> '
                         'here as a forecast, not a verdict.</div>')
            h.append('</details>')

        # liquidity-gate evidence
        sweep = mk.get('sweep') or []
        if sweep:
            h.append('<details class="sub"><summary>Why the gate is at '
                     f'{mk["gate"]} <span class="count">(threshold sweep)</span>'
                     '</summary>')
            h.append('<div class="card"><table>')
            h.append('<tr><th>Min leg volume</th><th>Books kept</th><th class="opt2">Completed</th>'
                     '<th>Completion rate</th><th>Total</th><th>Per book</th></tr>')
            best = max(s['per'] for s in sweep)
            for s in sweep:
                cls = 'good' if s['per'] > 0 else ('bad' if s['per'] < 0 else 'dim')
                is_live = mk['gate'] is not None and s['t'] == mk['gate']
                tag = ' <span class="pill k">live gate</span>' if is_live else ''
                peak = ' <span class="dim">← best</span>' if s['per'] == best else ''
                lbl = 'no gate' if s['t'] == 0 else f'&ge; {s["t"]:,}'
                h.append(f'<tr class="nosort"><td>{lbl}{tag}</td>'
                         f'<td>{s["books"]}</td><td class="dim opt2">{s["complete"]}</td>'
                         f'<td>{s["rate"]:.0f}%</td>'
                         f'<td class="{cls}">{s["pnl_c"]:+.0f}c</td>'
                         f'<td class="{cls}">{s["per"]:+.2f}c{peak}</td></tr>')
            h.append('</table></div>')
            h.append('<div class="note">Each row asks: <i>if we had only quoted books '
                     'whose thinnest leg had at least this much traded volume, what '
                     'would have happened?</i> Completion improves monotonically up to '
                     'roughly 500&ndash;1,000 and then <b>reverses</b> — the highest '
                     'threshold has the best completion rate and still loses money, '
                     'plausibly adverse selection, since the busiest markets are where '
                     'an informed counterparty is most likely to be the one lifting us. '
                     'Raising the gate is not free.<br><br>'
                     'This is <b>in-sample</b>: the threshold was chosen from these same '
                     'books. It is frozen from 2026-08-09 (regime v4) to be judged on '
                     'fresh ones. Books with no volume recorded are excluded rather than '
                     'counted as zero.</div>')
            h.append('</details>')

        # regime explainer -- what the label on each book row actually means
        REG_DOC = {
            'v1': ('flat <code>fair + 2c</code>, <b>uncapped</b> — sat above the '
                   'market&rsquo;s own ask on 86% of legs, so it essentially could not '
                   'fill. 3h window, fired once from a 5-minute cron.'),
            'v2': ('margin <b>staged</b> by hours-to-kickoff (6/5/3/2/1c), <b>capped</b> '
                   'at the market&rsquo;s own ask and <b>floored</b> at fair. 12h window, '
                   'persistent process polling every 20s.'),
            'v3': ('<b>delayed completion</b> — only cross into the bid inside the last '
                   'hour before kickoff, plus a 0&ndash;5c price ladder. Earlier regimes '
                   'completed the instant a book hit 2-of-3, in practice 8&ndash;10 '
                   '<i>hours</i> early, paying ~2c to cross while the third leg still had '
                   'most of its window to sell for free.'),
            'v4': ('v3 <b>plus the liquidity gate</b> — quote only books whose thinnest '
                   'leg has traded volume &ge;500. Changes <i>which books are quoted</i>, '
                   'not how they are priced, so it is a regime not a tweak: on 2026-08-09 '
                   'it admitted 4 of 30 otherwise-playable books. Aimed at partial fills, '
                   'which is where the losses live &mdash; a 2-of-3 book runs &minus;7.62c '
                   'while a complete one runs +1.38c.'),
        }
        regs = mk.get('regimes') or {}
        live = mk.get('live_regime')
        # Show EVERY documented regime, not just those with settled rows. The
        # live regime is the one you most want to see, and it necessarily has
        # n=0 until its first book settles -- omitting it made the current
        # strategy invisible on the page that describes the strategy.
        names = sorted(set(REG_DOC) | set(regs) | ({live} if live else set()))
        if names:
            h.append('<details class="sub"><summary>What the regimes mean '
                     f'<span class="count">(live: {esc(live or "?")})</span></summary>')
            h.append('<div class="card"><table>')
            h.append('<tr><th>Regime</th><th>Books</th><th class="opt2">Legs</th><th>P&amp;L</th>'
                     '<th>Per leg</th><th>Rule</th></tr>')
            for g in names:
                s = regs.get(g) or {'books': 0, 'legs': 0, 'pnl_c': 0.0}
                per = s['pnl_c'] / s['legs'] if s['legs'] else 0
                cls = 'good' if per > 0 else ('bad' if per < 0 else 'dim')
                tag = (' <span class="pill k">live</span>' if g == live else '')
                if s['legs']:
                    pnl_td = f'<td class="{cls}">{s["pnl_c"]:+.1f}c</td>'
                    per_td = f'<td class="{cls}">{per:+.2f}c</td>'
                else:
                    pnl_td = '<td class="dim">—</td>'
                    per_td = '<td class="dim">no settled books yet</td>'
                h.append(f'<tr class="nosort"><td><b>{esc(g)}</b>{tag}</td>'
                         f'<td>{s["books"] or "—"}</td>'
                         f'<td class="dim opt2">{s["legs"] or "—"}</td>'
                         f'{pnl_td}{per_td}'
                         f'<td style="white-space:normal;max-width:520px" class="dim">'
                         f'{REG_DOC.get(g, "&mdash;")}</td></tr>')
            h.append('</table></div>')
            h.append('<div class="note">Regimes are <b>different strategies, not '
                     'tweaks</b> — pooling them once produced a &ldquo;today&rsquo;s maker '
                     'result&rdquo; that blended three configurations logged hours apart. '
                     'Each book carries the regime stamped at post time, so a mixed day '
                     'cannot silently average them. Compare within a regime, never '
                     'across.</div>')
            h.append('</details>')

        h.append('<div class="note">A <b>complete</b> book earns the overround and is '
                 'outcome-independent; a <b>stranded</b> one is a naked position on '
                 'whichever side the market is moving toward — which is why 2-of-3 is '
                 'the worst state, not 1-of-3. The gate quotes only books liquid enough '
                 'to finish: on this history, min leg volume &ge;500 lifted completion '
                 '36% &rarr; 60% and turned &minus;1.84c/book into +3.01c. That threshold '
                 'was chosen on this same data, so it is <b>in-sample</b> — it is frozen '
                 'from 2026-08-09 to be judged on fresh books only.</div>')
        h.append('</details>')

    # "League wiring & live feeds" removed 2026-08-09 (user request). It was an
    # operator diagnostic -- 44 rows of yes/no plumbing -- on a page whose job is
    # tracking the rule. league_health() is still computed and still available to
    # research/venue_coverage_audit.py, so nothing that depended on the DATA is
    # lost; only the table is gone.

    h.append('</details>')
    h.append(f'<script>{JS}</script>')
    h.append('</body></html>')
    return '\n'.join(h)


# ── email flavour ─────────────────────────────────────────────────────────
# Separate renderer, same data layer. Email clients (Gmail mobile especially)
# strip <style> blocks and support no CSS variables, so everything here is
# inline-styled on a light background -- matching the conventions the existing
# XLOW/nightly emails already use successfully.

E_IN = 'padding:5px 8px;border-bottom:1px solid #ddd;white-space:nowrap'
E_TH = ('padding:5px 8px;border-bottom:2px solid #999;text-align:left;'
        'font-size:11px;text-transform:uppercase;color:#555;white-space:nowrap')
E_TBL = 'border-collapse:collapse;width:100%;font-size:13px;margin-bottom:4px'


def _e_pct(v, digits=1):
    if v is None:
        return '<span style="color:#999">&mdash;</span>'
    c = '#1a7f37' if v > 0 else ('#c1272d' if v < 0 else '#555')
    return f'<span style="color:{c}">{v:+.{digits}f}%</span>'


def _e_money(v, digits=2):
    if v is None:
        return '<span style="color:#999">&mdash;</span>'
    c = '#1a7f37' if v > 0 else ('#c1272d' if v < 0 else '#000')
    return f'<span style="color:{c}">${v:,.{digits}f}</span>'


def build_email():
    today = date.today()
    tom = today + timedelta(days=1)
    v = venue_state()
    ft = forward_test()
    sigs, sigs_hidden = scope_signals(
        signals_for({today.isoformat(), tom.isoformat()},
                    ftr_map=ft.get('ftr_map')), ft)
    health = league_health()
    k_pos, p_pos = v['kalshi']['positions'], v['polyus']['positions']
    at_risk = sum(x['cost'] for x in k_pos) + sum(x['cost'] for x in p_pos)
    prim = ft['rows'].get('xlow (PRIMARY · scoped)')

    h = ['<html><body style="font-family:Arial,Helvetica,sans-serif;font-size:13px;color:#111">']
    h.append(f'<h2 style="margin:0 0 2px;font-size:17px">Soccer xlow tracker</h2>')
    h.append(f'<div style="color:#666;font-size:12px;margin-bottom:12px">'
             f'{datetime.now().strftime("%Y-%m-%d %H:%M")} &middot; frozen {ft["freeze"]} '
             f'&middot; $1/order cap both venues</div>')

    # summary line
    kb = _e_money(v['kalshi']['balance']) if v['kalshi']['balance'] is not None else 'err'
    pb = _e_money(v['polyus']['balance']) if v['polyus']['balance'] is not None else 'err'
    h.append(f'<div style="background:#f4f6f8;border:1px solid #dde;padding:9px 11px;'
             f'border-radius:6px;margin-bottom:14px">'
             f'<b>Kalshi</b> {kb} &nbsp;|&nbsp; <b>Poly-US</b> {pb} &nbsp;|&nbsp; '
             f'<b>At risk</b> {_e_money(at_risk)} ({len(k_pos)+len(p_pos)} open)')
    if prim:
        h.append(f'<br><b>Forward test</b> n={prim["n"]} &nbsp; '
                 f'ROI {_e_pct(prim["roi"])} &nbsp; '
                 f'WR {prim["wr"]:.1f}% vs implied {prim["iwr"]:.1f}% '
                 f'({_e_pct(prim["wr"]-prim["iwr"])}) &nbsp; z={prim["z"]:+.2f}')
    h.append('</div>')

    # signals
    h.append('<h3 style="font-size:14px;margin:16px 0 6px">Signals &mdash; today '
             f'&amp; tomorrow ({len(sigs)})'
             + (f' <span style="font-weight:400;opacity:.6">&middot; {sigs_hidden} '
                f'out-of-scope hidden</span>' if sigs_hidden else '') + '</h3>')
    h.append(f'<table style="{E_TBL}"><tr>'
             + ''.join(f'<th style="{E_TH}">{c}</th>' for c in
                       ('Date', 'Lg', 'Match', 'Bet', 'Cheap', 'CLV K', 'CLV P', 'Traded', 'Result'))
             + '</tr>')
    if not sigs:
        h.append(f'<tr><td style="{E_IN}" colspan="9">no signals</td></tr>')
    for s in sigs:
        traded = ', '.join(s['traded']) or '<span style="color:#c1272d">not placed</span>'
        if s['settled']:
            pnl = s['settled']['pnl']
            res = (f'{esc(s["settled"]["ftr"])} &middot; {_e_money(pnl)}'
                   if pnl is not None else esc(s['settled']['ftr'] or '?'))
        else:
            res = '<span style="color:#999">open</span>'
        strat = '' if s['strategy'] == 'xlow' else f' <i style="color:#777">{esc(s["strategy"])}</i>'
        h.append(f'<tr><td style="{E_IN};color:#777">{esc(s["date"][5:])}</td>'
                 f'<td style="{E_IN}">{esc(s["div"])}</td>'
                 f'<td style="{E_IN}">{esc(s["match"])}{strat}</td>'
                 f'<td style="{E_IN}">{esc(s["bet"])}</td>'
                 f'<td style="{E_IN};color:#777">{esc(s["cheap"] or "&mdash;")}</td>'
                 f'<td style="{E_IN}">{_e_pct(s["clv_k"])}</td>'
                 f'<td style="{E_IN}">{_e_pct(s["clv_p"])}</td>'
                 f'<td style="{E_IN}">{traded}</td>'
                 f'<td style="{E_IN}">{res}</td></tr>')
    h.append('</table>')

    # positions
    h.append(f'<h3 style="font-size:14px;margin:16px 0 6px">Open positions ({len(k_pos)+len(p_pos)})</h3>')
    h.append(f'<table style="{E_TBL}"><tr>'
             + ''.join(f'<th style="{E_TH}">{c}</th>' for c in
                       ('Venue', 'Market', 'Qty', 'Cost', 'Value'))
             + '</tr>')
    for label, rows in (('Kalshi', k_pos), ('Poly-US', p_pos)):
        for x in rows:
            val = _e_money(x['value']) if x['value'] is not None else '<span style="color:#999">&mdash;</span>'
            h.append(f'<tr><td style="{E_IN}">{label}</td>'
                     f'<td style="{E_IN}">{esc(x["ref"])}</td>'
                     f'<td style="{E_IN};color:#777">{esc(x["qty"])}</td>'
                     f'<td style="{E_IN}">{_e_money(x["cost"])}</td>'
                     f'<td style="{E_IN}">{val}</td></tr>')
    if not (k_pos or p_pos):
        h.append(f'<tr><td style="{E_IN}" colspan="5">none open</td></tr>')
    h.append('</table>')

    # forward test rules
    h.append('<h3 style="font-size:14px;margin:16px 0 6px">Forward test (out-of-sample)</h3>')
    h.append(f'<table style="{E_TBL}"><tr>'
             + ''.join(f'<th style="{E_TH}">{c}</th>' for c in
                       ('Rule', 'n', 'WR', 'Implied', 'ROI/bet', 'z'))
             + '</tr>')
    for name, s in ft['rows'].items():
        if not s:
            continue
        h.append(f'<tr><td style="{E_IN}">{esc(name)}</td><td style="{E_IN}">{s["n"]}</td>'
                 f'<td style="{E_IN}">{s["wr"]:.1f}%</td>'
                 f'<td style="{E_IN};color:#777">{s["iwr"]:.1f}%</td>'
                 f'<td style="{E_IN}">{_e_pct(s["roi"])}</td>'
                 f'<td style="{E_IN};color:#777">{s["z"]:+.2f}</td></tr>')
    h.append('</table>')

    # league problems only -- full table is noise in email
    probs = []
    for L in health:
        if not L['can_fire']:
            continue
        s = L['live']
        missing = []
        if L['k_series'] and not s['kalshi']:
            missing.append('no Kalshi prices')
        if L['poly'] and not s['poly']:
            missing.append('no Poly.com prices')
        if L['teams'] and L['abbr'] < L['teams']:
            missing.append(f'{L["teams"]-L["abbr"]}/{L["teams"]} teams missing abbr')
        if missing:
            probs.append((L['code'], L['name'], '; '.join(missing)))
    h.append(f'<h3 style="font-size:14px;margin:16px 0 6px">League issues ({len(probs)})</h3>')
    if probs:
        h.append(f'<table style="{E_TBL}"><tr>'
                 + ''.join(f'<th style="{E_TH}">{c}</th>' for c in ('Lg', 'Name', 'Issue'))
                 + '</tr>')
        for code, name, issue in probs:
            h.append(f'<tr><td style="{E_IN}">{esc(code)}</td>'
                     f'<td style="{E_IN};color:#777">{esc(name)}</td>'
                     f'<td style="{E_IN};color:#c1272d">{esc(issue)}</td></tr>')
        h.append('</table>')
    else:
        h.append('<div style="color:#1a7f37">all xlow-capable leagues have live feeds</div>')

    h.append('<div style="color:#888;font-size:11px;margin-top:16px">'
             'Generated by main/dashboard.py. Thin Poly-US books mark to a low bid, '
             'which overstates unrealised losses.</div>')
    h.append('</body></html>')
    return '\n'.join(h)


def send_with_attachment(subject, body_html, attach_html, filename):
    """Email a short summary body with the full dashboard attached as .html.
    Rolled here rather than reusing daily_signals.send_email because that one
    is MIMEMultipart('alternative') with no attachment support."""
    import smtplib
    from email.mime.multipart import MIMEMultipart
    from email.mime.text import MIMEText
    from email.mime.application import MIMEApplication
    from daily_signals import EMAIL_ADDRESS, EMAIL_RECIPIENT, EMAIL_APP_PASSWORD
    if not EMAIL_ADDRESS or not EMAIL_APP_PASSWORD:
        log('  Email not configured (no .env). Skipping send.')
        return False
    msg = MIMEMultipart('mixed')
    msg['Subject'] = subject
    msg['From'] = EMAIL_ADDRESS
    msg['To'] = EMAIL_RECIPIENT
    msg.attach(MIMEText(body_html, 'html'))
    att = MIMEApplication(attach_html.encode('utf-8'), _subtype='html')
    att.add_header('Content-Disposition', 'attachment', filename=filename)
    msg.attach(att)
    with smtplib.SMTP_SSL('smtp.gmail.com', 465) as srv:
        srv.login(EMAIL_ADDRESS, EMAIL_APP_PASSWORD)
        srv.sendmail(EMAIL_ADDRESS, EMAIL_RECIPIENT, msg.as_string())
    return True


def summary_body():
    """Short at-a-glance body so the email is useful without opening the attachment."""
    v = venue_state()
    ft = forward_test()
    sigs, sigs_hidden = scope_signals(
        signals_for({date.today().isoformat(),
                     (date.today() + timedelta(days=1)).isoformat()}), ft)
    k_pos, p_pos = v['kalshi']['positions'], v['polyus']['positions']
    at_risk = sum(x['cost'] for x in k_pos) + sum(x['cost'] for x in p_pos)
    prim = ft['rows'].get('xlow (PRIMARY · scoped)')
    untraded = sum(1 for s in sigs if not s['traded'] and not s['settled'])

    h = ['<html><body style="font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#111">']
    h.append(f'<h2 style="margin:0 0 3px;font-size:17px">Soccer xlow tracker</h2>')
    h.append(f'<div style="color:#666;font-size:12px;margin-bottom:12px">'
             f'{datetime.now().strftime("%Y-%m-%d %H:%M")} &middot; full dashboard attached</div>')
    kb = _e_money(v['kalshi']['balance']) if v['kalshi']['balance'] is not None else 'err'
    pb = _e_money(v['polyus']['balance']) if v['polyus']['balance'] is not None else 'err'
    h.append('<table style="border-collapse:collapse;font-size:14px">')
    def row(k, val):
        h.append(f'<tr><td style="padding:3px 12px 3px 0;color:#555">{k}</td>'
                 f'<td style="padding:3px 0"><b>{val}</b></td></tr>')
    # 'balance' is now the TOTAL (all subaccounts + open positions), so the
    # label has to say so -- the email and the page must not disagree about
    # what the same number means.
    row('Kalshi total', kb)
    # 'balance' is the TOTAL for both venues now, so the labels must match
    # what the page says -- see the Kalshi row above.
    row('Poly-US total', pb)
    row('At risk', f'{_e_money(at_risk)} &nbsp;<span style="color:#777;font-weight:normal">'
                   f'({len(k_pos)+len(p_pos)} open)</span>')
    row('Signals (today+tmrw)', f'{len(sigs)}' + (
        f' &nbsp;<span style="color:#c1272d;font-weight:normal">{untraded} not placed</span>'
        if untraded else ''))
    if prim:
        row('Forward test', f'n={prim["n"]} &nbsp; ROI {_e_pct(prim["roi"])} '
                            f'&nbsp; z={prim["z"]:+.2f}')
        row('WR vs implied', f'{prim["wr"]:.1f}% vs {prim["iwr"]:.1f}% '
                             f'({_e_pct(prim["wr"]-prim["iwr"])})')
    h.append('</table>')
    h.append('<div style="color:#888;font-size:12px;margin-top:14px">'
             'Open the attached <b>dashboard.html</b> for signals, positions, '
             'per-league forward test and league wiring health.</div>')
    h.append('</body></html>')
    return '\n'.join(h)


if __name__ == '__main__':
    if '--email' in sys.argv:
        full = build()
        OUT.write_text(full, encoding='utf-8')          # keep local copy fresh too
        prim = forward_test()['rows'].get('xlow (PRIMARY · scoped)')
        subj = 'DASHBOARD ' + datetime.now().strftime('%m-%d %H:%M')
        if prim:
            subj += f" | fwd n={prim['n']} ROI {prim['roi']:+.1f}%"
        # attach the full dark/responsive build() -- renders perfectly in the
        # phone browser, sidestepping every email-client CSS limitation.
        send_with_attachment(subj, summary_body(), full, 'dashboard.html')
        log(f'emailed (attachment): {subj}')
    elif '--email-inline' in sys.argv:
        # fallback: inline email-safe rendering, for clients that block attachments
        from daily_signals import send_email
        send_email('DASHBOARD ' + datetime.now().strftime('%m-%d %H:%M'), build_email())
        log('emailed (inline fallback)')
    else:
        html = build()
        OUT.write_text(html, encoding='utf-8')
        log(f'wrote {OUT}  ({len(html):,} bytes)')
