#!/usr/bin/env python3
"""Polymarket copy-trade engine.

Watches a list of wallets and mirrors their trades onto your own account:
  - sizing: class % of current equity per new entry (capped at the signal's
            own bet, floored at the venue's $1 minimum order)
  - mirror: entries AND exits (sells are mirrored proportionally)
  - guard:  skip a copy if the price rose >5 POINTS above their fill
            (absolute — 0.14→0.15 follows; better-than-theirs never blocked)

This is the ENGINE ONLY (the standalone CLI was retired 2026-07-19 — it had
been broken since the 07-10 price_guard rename, proof nobody ran it; closes
#6). copybot.py is the sole runner: it wires the engine to detection, the
executors, and the book. Hard risk caps and the paper-default safety model
live in DEFAULT_CONFIG + confirm_live().
"""

import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

# reuse the scanner's hardened HTTP helper (SSL fallback, retries)
from smart_money import get_json, SSL_CTX  # noqa: E402

DATA_API = "https://data-api.polymarket.com"
CLOB_API = "https://clob.polymarket.com"
POLYGON_CHAIN_ID = 137
CONFIRM_PHRASE = "TRADE LIVE"

# Polymarket rejects orders under this many shares regardless of dollar
# value ("Size (1.47) lower than the minimum: 5", observed live 2026-07-27).
# It makes the real floor PRICE-dependent: a 90c market needs $4.50, a 20c
# market $1.00 — which is why a $1-stake book can only trade deep longshots.
MIN_SHARES = 5.0

DEFAULT_CONFIG = {
    "mode": "paper",                 # "paper" or "live"
    "poll_seconds": 12,              # how often to check each wallet
    "discord_webhook": "",           # paste a Discord webhook URL to get pings
    "watchlist": [],                 # ["0xwallet1", "0xwallet2", ...]
    "bankroll_usd": 1000.0,          # starting stake pool
    "bankroll_pct": 0.02,            # fraction of CURRENT equity per new entry
                                     # (compounds up and down; falls back to a flat
                                     # fraction of bankroll_usd when cash isn't tracked)
                                     # past cap/bankroll_pct — surplus cash is SWEPT to
                                     # state["reserve"], a banked pot that never bets
                                     # (profit ratchet + keeps fills inside book depth)
    "price_guard_abs": 0.05,         # skip if price moved >5 POINTS above their
                                     # fill (absolute, 2026-07-10: 0.14→0.15 must
                                     # follow; relative % blocked 1-tick moves on
                                     # cheap in-play books). VALIDATED 2026-07-13
                                     # by the missed-ledger counterfactuals: the
                                     # 0.05 line sits at the EV knee (0.05-0.10
                                     # moves ≈ breakeven, >0.10 = −20% ROI).
    "depth_gate": {                  # 2026-07-13, fitted on 131 gated fills —
        "max_spread": 0.08,          # RETIRED as a skip 2026-07-19 (lag-era
                                     # relic) — kept for config compatibility
        "min_ask5c": 50.0,           # dust books mispriced every observed fill
        "max_frac_of_ask5c": 0.10,   # stake ≤10% of 5c ask depth (impact <~2%)
    },
    "risk": {
        "max_trade_usd": 50.0,       # hard ceiling on any single copy
        "max_position_usd": 40.0,    # hard ceiling on total cost in one market
        "daily_spend_cap_usd": 250.0,
        "max_total_exposure_usd": 500.0,
        "max_open_positions": 20,
        "max_per_event": 0,          # >0 caps concurrent positions on one real-world
                                     # event (a game's markets are one correlated bet);
                                     # 0 = off — follow every conviction trade
        "min_price": 0.05,           # don't open longshots/near-certainties
        "max_price": 0.95,
        "min_order_usd": 5.0,        # Polymarket min order size
    },
    # live credentials — only read in live mode
    "live": {
        "private_key": "",           # EOA key that controls the funds
        "funder_address": "",        # proxy wallet holding USDC (sig type 1/2)
        "signature_type": 1,         # 0 EOA · 1 email/magic proxy · 2 browser proxy
    },
}

STATE_PATH_DEFAULT = "copytrade_state.json"


def post_discord(webhook, content):
    """POST a message to a Discord webhook. Best-effort; never raises."""
    if not webhook:
        return False
    try:
        body = json.dumps({"content": content}).encode()
        req = urllib.request.Request(
            webhook, data=body, method="POST",
            headers={"Content-Type": "application/json",
                     "User-Agent": "Mozilla/5.0"})
        urllib.request.urlopen(req, timeout=10, context=SSL_CTX).read()
        return True
    except (urllib.error.URLError, TimeoutError):
        return False


# ── state ─────────────────────────────────────────────────────────────────

def load_json(path, default):
    if os.path.exists(path):
        with open(path) as f:
            return json.load(f)
    return default


def save_json(path, data):
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        json.dump(data, f, indent=2)
    os.replace(tmp, path)


class _SeenTx(dict):
    """Set-compatible seen-tx registry that remembers insertion recency so
    persist() can keep the NEWEST N (membership: `tx in seen`; value = a
    monotonically increasing sequence number)."""
    _n = 0

    def __init__(self, items=()):
        super().__init__(items)
        if self:      # resume the sequence ABOVE loaded entries, or fresh
            _SeenTx._n = max(_SeenTx._n, max(self.values()) + 1)

    def add(self, tx):
        _SeenTx._n += 1
        self[tx] = _SeenTx._n

    def update(self, txs):
        for t in txs:
            self.add(t)


def new_state():
    return {
        "started_at": time.time(),
        "seen_tx": [],               # transactionHashes already processed
        "their_pos": {},             # wallet -> {token_id: shares}, live-tracked
        "seed_tokens": {},           # wallet -> [token_id] held when we started
        "my_pos": {},                # token_id -> {"shares", "cost", "title", "outcome"}
        "spend": {"date": "", "usd": 0.0},
        "seeded": [],                # wallets whose starting positions we loaded
    }


# ── market data ─────────────────────────────────────────────────────────────

def clob_price(token_id, side):
    """Best price to trade `side` ('buy'/'sell') on this token, or None."""
    try:
        url = f"{CLOB_API}/price?token_id={token_id}&side={side}"
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as r:
            return float(json.loads(r.read().decode())["price"])
    except (urllib.error.URLError, KeyError, ValueError, TimeoutError):
        return None


def book_depth(token_id):
    """Top-of-book + $-depth within 5c of touch — the DEPTH GATE's input
    (2026-07-13, fitted on 131 gated fills). Returns {bb, ba, spread, bid5c,
    ask5c} or None on any failure (the gate then declines to bind — the
    price guard and protected prices still bound the copy)."""
    try:
        url = f"{CLOB_API}/book?token_id={token_id}"
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=8, context=SSL_CTX) as r:
            b = json.loads(r.read().decode())
        bids, asks = b.get("bids") or [], b.get("asks") or []
        bb = max((float(x["price"]) for x in bids), default=None)
        ba = min((float(x["price"]) for x in asks), default=None)

        def depth(side, ref, sgn):
            if ref is None:
                return None
            return round(sum(float(x["size"]) * float(x["price"]) for x in side
                             if sgn * (float(x["price"]) - ref) >= -0.05), 2)
        return {"bb": bb, "ba": ba,
                "spread": round(ba - bb, 4) if bb is not None and ba is not None else None,
                "bid5c": depth(bids, bb, 1), "ask5c": depth(asks, ba, -1)}
    except Exception:
        return None


def their_positions(wallet):
    """Current open positions -> {token_id: shares}, for exit-fraction math.
    Cap is generous: a whale can hold >500 open positions, and a position
    missing from the seed both breaks no-backfill (their ADD to an old
    position looks like a fresh OPEN) and the sell-fraction math."""
    pos = {}
    offset = 0
    while offset < 2000:
        page = get_json("/positions",
                        {"user": wallet, "limit": 50, "offset": offset,
                         "sizeThreshold": 0.1})
        if not page:
            break
        for p in page:
            if p.get("asset"):
                pos[p["asset"]] = pos.get(p["asset"], 0) + p.get("size", 0)
        offset += 50
        if len(page) < 50:
            break
    return pos


def recent_trades(wallet, limit=100, offset=0):
    """Newest-first TRADE activity for a wallet. `offset` pages deeper —
    the copybot's per-wallet cursor fetch (H3) walks back through bursts
    that overflow a single page."""
    return get_json("/activity",
                    {"user": wallet, "type": "TRADE", "limit": limit,
                     "offset": offset}) or []


def event_key(t):
    """Correlation-group id for a trade: the real-world event its market belongs
    to. Polymarket sub-splits one game across several eventSlugs
    (`…-2026-07-01-more-markets`, `…-2026-07-01-second-half-result`), so dated
    slugs collapse to their `…-YYYY-MM-DD` prefix; undated slugs stand as-is."""
    ev = t.get("eventSlug") or t.get("slug") or ""
    m = re.match(r"(.*?\d{4}-\d{2}-\d{2})", ev)
    return m.group(1) if m else (ev or None)


# ── execution ────────────────────────────────────────────────────────────────

class PaperExecutor:
    """Simulates fills at the current best price. Places nothing.

    BUYs model live FAK reality (2026-07-15): live sends fill-and-kill with a
    protected cap of quote × (1 + max_slippage_pct); on a thin book with no
    ask inside that band the order dies ('no orders found to match') — the #1
    live miss class once detection got fast (the copy arrives in the crater
    the sharp just swept, before makers requote). Paper used to 'fill' those,
    which biased the live-vs-paper per-signal ratio — the bankroll top-up
    number — optimistic. Now the same book snapshot the depth gate fetched
    (meta['book'], else fetched here) decides: no ask ≤ cap -> rejected, and
    the engine records the same 'order rejected' miss live would. A failed
    book fetch fails OPEN (fills, today's behavior) — a dead data source
    shouldn't fabricate misses. SELLs stay optimistic-fill: exits are
    proportional mirrors of a position we hold, and modeling exit failure
    honestly needs the retry/pending machinery, not a one-line reject."""
    live = False
    max_slippage_pct = 0.05        # mirrors live.max_slippage_pct's default

    def buy(self, token_id, shares, price, meta):
        bk = (meta or {}).get("book")
        if bk is None:
            bk = book_depth(token_id)
        if bk is not None:
            ba, cap = bk.get("ba"), price * (1 + self.max_slippage_pct)
            if ba is None or ba > cap:
                gone = ("no asks" if ba is None
                        else f"best ask {ba:.3f} > cap {cap:.3f}")
                return {"ok": False, "filled_shares": 0.0, "price": price,
                        "resp": "no orders found to match with FAK order "
                                f"(paper model: {gone})", "paper": True}
        return {"ok": True, "filled_shares": shares, "price": price, "paper": True}

    def maker_buy(self, token_id, shares, price, meta):
        """#20 dark path (paper): a resting bid never fills at placement —
        the host's paper-maker registry fills it from RTDS prints <= bid
        within TTL, or expires it into an honest miss."""
        import math
        sz = math.floor(shares * 100) / 100.0
        return {"ok": False, "filled_shares": 0.0, "price": price,
                "pending": {"paper_maker": True, "sz": sz, "mode": "maker"},
                "paper": True}

    def sell(self, token_id, shares, price, meta):
        return {"ok": True, "filled_shares": shares, "price": price, "paper": True}



class CopyTrader:
    def __init__(self, cfg, state, executor, state_path):
        self.cfg = cfg
        self.state = state
        self.ex = executor
        self.state_path = state_path
        self.risk = cfg["risk"]
        self.seen = _SeenTx((tx, i) for i, tx in enumerate(state["seen_tx"]))
        self.webhook = cfg.get("discord_webhook", "")
        self._discord_warned = False
        # host-installed hook (copybot.Copybot.fak_requote_retry): called with
        # the copy context when an OPEN's FAK dies unmatched, instead of
        # recording the miss immediately. None = old behavior (miss at once).
        self.on_fak_reject = None
        # host-installed hook (copybot.Copybot.spool_missed): receives rows
        # trimmed off state["missed"] so the full-history feed keeps every
        # skip ever (2026-07-21). None = trimmed rows are simply dropped.
        self.on_miss_spool = None
        # host-installed hook (#21 hold-through): receives each ignored
        # mirror-exit so the sell counterfactual stays exactly computable
        # forward. None = ignored exits are only console-logged.
        self.on_ignored_exit = None

    # -- helpers --
    def log(self, msg):
        print(f"{time.strftime('%H:%M:%S')}  {msg}", flush=True)

    def alert(self, msg, discord_text=None):
        """Log to console AND push to Discord (used for actual placements)."""
        self.log(msg)
        if self.webhook:
            ok = post_discord(self.webhook, discord_text or msg)
            if not ok and not self._discord_warned:
                self.log("  ⚠ Discord webhook post failed (check the URL)")
                self._discord_warned = True

    def reset_daily_if_needed(self):
        today = time.strftime("%Y-%m-%d")
        if self.state["spend"]["date"] != today:
            self.state["spend"] = {"date": today, "usd": 0.0}

    def open_exposure(self):
        return sum(p["cost"] for p in self.state["my_pos"].values())

    # ---- dynamic sizing: fraction of CURRENT equity, with a drawdown brake ----
    DD_THRESHOLD = 0.80      # below 80% of the high-water mark…
    DD_FACTOR = 0.5          # …bet half size until equity recovers

    def wallet_class(self, wallet=None):
        """'volume' (default) or 'whale', from follow.wallet_class ({address: class})."""
        classes = (self.cfg.get("follow") or {}).get("wallet_class") or {}
        return classes.get((wallet or "").lower(), "volume")

    def stake_frac(self, wallet=None):
        """Equity fraction for this wallet's signals, by class:
        follow.class_pct maps class -> fraction (e.g. volume 0.04, whale 0.12).
        A class missing from class_pct falls back to bankroll_pct."""
        pcts = (self.cfg.get("follow") or {}).get("class_pct") or {}
        pct = pcts.get(self.wallet_class(wallet))
        return self.cfg["bankroll_pct"] if pct is None else float(pct)

    def stake_usd(self, wallet=None, their=None):
        """Next bet size = the wallet's class fraction (stake_frac) × current
        WORKING equity (cash + open cost basis), halved in a >20% drawdown from
        the high-water mark — and NEVER larger than the followed wallet's own
        stake (`their` = the signal's position size so far): when the
        percentage works out to more than they actually bet, mirror their
        exact amount. The stake cap and banked-reserve sweep are retired
        (2026-07-06, with the backtest's banking logic) — the their-bet
        ceiling is the liquidity bound now: fills stay within size the market
        demonstrably absorbed from the signal itself. Falls back to the flat
        static stake when cash isn't tracked (legacy poll CLI)."""
        frac = self.stake_frac(wallet)
        cash = self.state.get("cash")
        if cash is None:
            stake = self.cfg["bankroll_usd"] * frac
        else:
            eq = cash + self.open_exposure()
            hwm = max(self.state.get("hwm", 0.0), eq)
            self.state["hwm"] = hwm
            if eq < self.DD_THRESHOLD * hwm:
                frac *= self.DD_FACTOR
            stake = frac * eq
        if their and stake > their:
            stake = their
        # venue floor: the CLOB rejects sub-$1 orders, so a small book's pct
        # stake must round UP to the minimum or every copy dies at the gate
        # (4% of the $22 live book = $0.89 — 2026-07-10 paper-parity retune)
        return max(stake, self.risk.get("min_order_usd", 1.0))

    def record_miss(self, wallet, token, cond, title, outcome, price, want, reason):
        """A bet the strategy WOULD have copied but the book couldn't take —
        free cash gone, event cap, price drifted past the guard. Kept in state so
        the live feed can show missed bets and (once the market resolves) their
        would-be P&L — the live counterpart of the backtest's Missed table."""
        missed = self.state.setdefault("missed", [])
        if any(m["token"] == token and m["status"] == "open" for m in missed):
            return                                  # already recorded while open
        missed.append({"ts": int(time.time()), "wallet": wallet, "token": token,
                       "cond": cond, "title": title, "outcome": outcome,
                       "price": round(price or 0, 4), "stake": round(want, 2),
                       "reason": reason, "status": "open", "pnl": None,
                       "settled": None})
        # cap the STATE copy at 200, spooling the overflow to the host's
        # archive so history survives (the old flat trim silently deleted).
        # SETTLED rows spool first — an OPEN miss must stay in state or the
        # heartbeat's missed-settle pass can never resolve its would-be P&L.
        if len(missed) > 200:
            order = sorted(range(len(missed)),
                           key=lambda i: (missed[i]["status"] == "open",
                                          missed[i]["ts"]))
            drop_i = set(order[:len(missed) - 200])
            drop = [m for i, m in enumerate(missed) if i in drop_i]
            if self.on_miss_spool is not None:
                try:
                    self.on_miss_spool(drop)
                except Exception:
                    drop = []               # archive failed: keep rows in state
            if drop:
                missed[:] = [m for i, m in enumerate(missed) if i not in drop_i]

    def persist(self):
        # newest-5000 by recency (audit 3.8: a set slice kept an ARBITRARY
        # 5000 — a crash+fast-restart could re-copy a fresh trade)
        self.state["seen_tx"] = sorted(self.seen, key=self.seen.get)[-5000:]
        save_json(self.state_path, self.state)

    # -- risk gate: returns (allowed_usd, reason_if_blocked) --
    def gate_buy(self, want_usd, price, pos_cost=0.0):
        r = self.risk
        if not (r["min_price"] <= price <= r["max_price"]):
            return 0.0, f"price {price:.3f} outside [{r['min_price']},{r['max_price']}]"
        if len(self.state["my_pos"]) >= r["max_open_positions"]:
            return 0.0, f"max open positions ({r['max_open_positions']}) reached"
        self.reset_daily_if_needed()
        # free cash, when tracked (copybot maintains state["cash"], recycled on
        # sell + resolution). All-or-nothing like the dashboard's `if(cash>=stake)`:
        # a bet we can't fully fund from free cash is a MISS, not a partial fill.
        cash = self.state.get("cash")
        if cash is not None and cash < want_usd:
            return 0.0, (f"capital fully deployed (free ${cash:.2f} < "
                         f"stake ${want_usd:.2f})")
        caps = [
            want_usd,
            r["max_trade_usd"],
            r.get("max_position_usd", float("inf")) - pos_cost,
            r["daily_spend_cap_usd"] - self.state["spend"]["usd"],
            r["max_total_exposure_usd"] - self.open_exposure(),
        ]
        if cash is not None:
            caps.append(cash)            # never deploy more than free cash
        allowed = min(caps)
        if allowed < r["min_order_usd"]:
            return 0.0, (f"capped to ${allowed:.2f} < min order "
                         f"${r['min_order_usd']:.2f} (caps)")
        # VENUE SHARE MINIMUM (2026-07-27): Polymarket rejects any order
        # under MIN_SHARES shares — "Size (1.47) lower than the minimum: 5"
        # — which is a PRICE-dependent dollar floor the $-only gate above
        # cannot see. At a $1 stake that caps us to <=20c markets; every
        # 40-90c signal fired a doomed order and booked a venue rejection.
        # Skip honestly instead, and say what the bet would have needed.
        need = MIN_SHARES * price
        if allowed < need - 1e-9:
            return 0.0, (f"below venue {MIN_SHARES:g}-share minimum: "
                         f"${allowed:.2f} at {price:.2f} = "
                         f"{allowed / price:.2f} shares (needs ${need:.2f})")
        return allowed, None

    # -- process one of their trades --
    def handle_trade(self, wallet, t):
        tx = t.get("transactionHash")
        if not tx or tx in self.seen:
            return
        token = t.get("asset")
        side = t.get("side")          # BUY / SELL
        their_size = t.get("size", 0)
        their_price = t.get("price", 0)
        title = t.get("title", "?")
        outcome = t.get("outcome", "?")
        label = f"{outcome} · {title[:42]}"

        their_book = self.state["their_pos"].setdefault(wallet, {})
        their_prev = their_book.get(token, 0)

        if side == "BUY":
            self._handle_their_buy(wallet, token, their_size, their_price,
                                   label, title, outcome, event=event_key(t),
                                   cond=t.get("conditionId"),
                                   their_ts=t.get("timestamp"))
            their_book[token] = their_prev + their_size
        elif side == "SELL":
            self._handle_their_sell(token, their_size, their_prev, label)
            their_book[token] = max(0.0, their_prev - their_size)

        self.seen.add(tx)
        self.persist()

    def _live_price(self, token, side):
        p = clob_price(token, side)
        if p is None:
            self.log(f"  ⚠ no live price for token, skipping")
        return p

    def _price_guard_ok(self, current, their_price):
        if their_price <= 0:
            return True
        # ASYMMETRIC by rule: a better price than the sharp paid is never blocked
        # (paying less for the same outcome is strictly better odds — the guard
        # once skipped a 0.70→0.51 improvement that went on to win). Only adverse
        # drift — chasing the price UP — is gated, in ABSOLUTE points: the old
        # relative 5% blocked one-tick moves on cheap in-play books (0.14→0.15
        # is +7% relative but the same bet; 0.14→0.19 is where the edge is gone).
        if current <= their_price:
            return True
        guard = self.cfg.get("price_guard_abs",
                             self.cfg.get("price_guard_pct", 0.05))
        return (current - their_price) <= guard

    def _handle_their_buy(self, wallet, token, their_size, their_price,
                          label, title, outcome, event=None, cond=None,
                          their_ts=None, retry=False):
        # retry=True: re-entry from the host's FAK re-quote retry. The caller
        # passes their_size=0 — their_pos already counts the copied trade, so
        # passing the size again would double-count their stake in the ceiling.
        mine = self.state["my_pos"].get(token)
        is_add = mine is not None
        # the signal's position in this token BEFORE this trade — the their-bet
        # ceiling needs it in BOTH branches (their_prev + their_size = their
        # total stake). Defining it only inside the is_add branch made every
        # fresh OPEN crash with UnboundLocalError since the ceiling landed
        # 2026-07-06 — swallowed as a webhook "handler error", so the bot
        # silently placed NO new positions (last fill 2026-07-05, found 07-07).
        their_prev = self.state["their_pos"].get(wallet, {}).get(token, 0)
        # don't backfill: never open a position they already held when we
        # started watching. (A position we built during the run is an ADD;
        # a brand-new position they opened after start is a fresh OPEN.)
        if not is_add and token in self.state["seed_tokens"].get(wallet, []):
            self.log(f"BUY  {label} — skip (held before we started, no backfill)")
            return
        # correlation cap: a game's markets settle together — N bets on one event
        # are one big bet, not N diversified ones (LSB1 once stacked 6 markets on
        # a single match). Cap concurrent positions per real-world event.
        cap = self.risk.get("max_per_event")
        if not is_add and event and cap:
            held = sum(1 for p in self.state["my_pos"].values()
                       if p.get("event") == event)
            if held >= cap:
                self.log(f"BUY  {label} — skip (already {held} positions on this "
                         f"event, cap {cap})")
                self.record_miss(wallet, token, cond, title, outcome, their_price,
                                 self.stake_usd(wallet), f"event cap ({held} held)")
                return

        price = self._live_price(token, "buy")
        if price is None:
            # thin/one-sided book — the sharp is often the MAKER here (weekly
            # scalars especially), so there is no ask to lift when the copy
            # arrives. This silent return hid 9 followed-but-unplaced fills
            # across both books on 2026-07-09; a blocked OPEN is a missed bet.
            if not is_add:
                self.record_miss(wallet, token, cond, title, outcome,
                                 their_price, self.stake_usd(wallet),
                                 "no ask side on the book at copy time")
            return
        if not self._price_guard_ok(price, their_price):
            self.log(f"BUY  {label} — skip (price {price:.3f} vs their "
                     f"{their_price:.3f}, moved >"
                     f"{self.cfg.get('price_guard_abs', 0.05):.2f} abs)")
            self.record_miss(wallet, token, cond, title, outcome, price,
                             self.stake_usd(wallet),
                             f"price moved {their_price:.2f}→{price:.2f}")
            return

        if is_add:
            # proportional add: grow my position by the same fraction they did —
            # but SIZING DISCIPLINE binds per market: total position cost never
            # exceeds the current stake rule. Unbounded mirroring once took one
            # game to 2.15 stakes ($90 on a $42-stake book) when fortuneking
            # doubled into his own bet; the backtest is one-market-one-stake,
            # so the bot must be too.
            frac = their_size / their_prev if their_prev > 0 else 0
            want_shares = mine["shares"] * frac
            room = self.stake_usd(wallet, their_prev + their_size) - mine["cost"]
            if room < self.risk["min_order_usd"]:
                self.log(f"ADD  {label} — skip (position ${mine['cost']:.0f} already "
                         f"at the stake size)")
                return
            want_usd = min(want_shares * price, room)
            kind = "ADD "
        else:
            want_usd = self.stake_usd(wallet, their_prev + their_size)  # class % of equity, capped at their bet
            kind = "OPEN"

        pos_cost = mine["cost"] if is_add else 0.0
        allowed, reason = self.gate_buy(want_usd, price, pos_cost)
        if reason:
            self.log(f"{kind} {label} — skip ({reason})")
            if not is_add:                          # a blocked OPEN is a missed bet
                self.record_miss(wallet, token, cond, title, outcome, price,
                                 want_usd, reason)
            return
        # DEPTH GATE (2026-07-13, fitted on 131 book-annotated fills): the
        # book must absorb the stake. Fills into <$90 of 5c-depth paid
        # +2.6–4.2%; >20% of visible depth made >+2% slippage 33–50% likely;
        # spread>0.08 books ran median |slip| ~14% (market mid-move). Shrink
        # to 10% of depth, skip dust/unreliable books. A failed book fetch
        # declines to bind — guard + protected prices still bound the copy.
        dg = self.cfg.get("depth_gate")
        bk = None                       # reused by the paper FAK model below
        if dg:
            bk = book_depth(token)
            if bk and bk.get("ask5c") is not None:
                d_reason = None
                # SPREAD SKIP RETIRED (2026-07-19): the 0.08 rule was fitted in
                # the 39-90s-lag era, when a wide spread meant the book had
                # repriced under a late copy (med |slip| ~14%). At RTDS-era
                # ~4s lag the mechanism is gone (median |slip| 3.7%→1.7%,
                # Kruto+gkmg in-play mean +0.4%), and the skip was firing on
                # the informed wallets' BEST moments — in-play chaos IS their
                # signal (17 resolved skips ran 12W/5L, +$11 live/+$674 paper
                # would-be). Overpay is already bounded twice: the price guard
                # (their fill +0.05 abs) and the FAK protected band. The
                # depth-based stake cap and dust skip below KEEP earning.
                if bk["ask5c"] < dg["min_ask5c"]:
                    d_reason = (f"thin book (${bk['ask5c']:.0f} within 5c < "
                                f"${dg['min_ask5c']:.0f})")
                else:
                    cap = dg["max_frac_of_ask5c"] * bk["ask5c"]
                    if cap < self.risk["min_order_usd"]:
                        d_reason = (f"depth cap ${cap:.2f} below min order "
                                    f"(ask5c ${bk['ask5c']:.0f})")
                    elif cap < allowed:
                        self.log(f"{kind} {label} — depth gate shrinks "
                                 f"${allowed:.2f} → ${cap:.2f} "
                                 f"(10% of ${bk['ask5c']:.0f} ask depth)")
                        allowed = cap
                if d_reason:
                    self.log(f"{kind} {label} — skip ({d_reason})")
                    if not is_add:
                        self.record_miss(wallet, token, cond, title, outcome,
                                         price, allowed, d_reason)
                    return
        # ONE outstanding in-play hold per token: a second order while a
        # pending rests re-buys the same signal and poisons the resolver's
        # balance-diff window (2026-07-12: overlapping pendings booked one
        # real fill several times → +$7.86 phantom cash).
        if any(po.get("token") == token
               for po in self.state.get("pending_orders", [])):
            self.log(f"{kind} {label} — skip (in-play hold already pending "
                     "on this token)")
            return
        f = self.cfg.get("follow") or {}
        maker = f.get("entry_mode", "taker") == "maker"
        if maker:
            # #20: rest at THEIR price (not the current ask) — same gated
            # opportunity set as taker mode, only the execution differs
            entry_px = their_price
            shares = allowed / entry_px
            res = self.ex.maker_buy(token, shares, entry_px,
                                    {"title": title, "book": bk})
        else:
            entry_px = price
            shares = allowed / price
            res = self.ex.buy(token, shares, price,
                              {"title": title, "book": bk})
        if not res["ok"]:
            # in-play books ACCEPT orders with a delayed hold — the executor
            # reports those as pending (order id + pre-order balance) instead
            # of failed. Park the full copy context; the heartbeat resolver
            # adopts the fill when it lands or converts to a miss at TTL.
            # (Maker bids park the same way with their own, shorter TTL.)
            if res.get("pending"):
                pend = {
                    **res["pending"], "token": token, "side": "BUY",
                    "wallet": wallet, "title": title, "outcome": outcome,
                    "event": event, "cond": cond, "their_price": their_price,
                    "their_ts": their_ts, "price": entry_px, "is_add": is_add,
                    "stake": allowed, "ts": time.time(),
                    "ttl_s": (float(f.get("maker_ttl_s", 60))
                              if res["pending"].get("mode") == "maker"
                              else 600),
                    "ba_at_signal": (bk or {}).get("ba")}
                if res["pending"].get("paper_maker"):
                    self.state.setdefault("paper_maker", []).append(pend)
                    self.log(f"{kind} {label} — MAKER bid {entry_px:.3f} "
                             f"resting (paper, {pend['ttl_s']:.0f}s)")
                else:
                    self.state.setdefault("pending_orders", []).append(pend)
                    self.log(f"{kind} {label} — PENDING ("
                             f"{'maker bid' if pend.get('mode') == 'maker' else 'in-play hold'}, "
                             f"order {str(res['pending'].get('order_id'))[:14]}…)")
                self.persist()
                return
            resp_s = str(res.get("resp"))
            self.log(f"{kind} {label} — ORDER FAILED: {resp_s}")
            # FAK no-match = the copy landed in the crater the sharp just
            # swept (see PaperExecutor) — makers usually restore the ask side
            # within seconds. Hand a first-attempt OPEN to the host's one-shot
            # re-quote retry instead of recording the miss now; the retry
            # re-runs this whole gated path (fresh quote, price guard, depth
            # gate) exactly once, and its failure lands in the branch below.
            if (not retry and not is_add and self.on_fak_reject is not None
                    and "no orders found to match" in resp_s):
                self.on_fak_reject({
                    "wallet": wallet, "token": token,
                    "their_price": their_price, "label": label,
                    "title": title, "outcome": outcome,
                    "event": event, "cond": cond, "their_ts": their_ts})
                return
            if not is_add:                     # a rejected OPEN is a missed bet
                tag = " twice (re-quote retry)" if retry else ""
                self.record_miss(wallet, token, cond, title, outcome, price,
                                 allowed, f"order rejected{tag}: {resp_s[:60]}")
            return
        spent = res["filled_shares"] * res["price"]
        self.state["spend"]["usd"] += spent
        if is_add:
            mine["shares"] += res["filled_shares"]
            mine["cost"] += spent
            if cond:
                mine.setdefault("cond", cond)
        else:
            # wallet/cond ride on the position so the book can always self-repair:
            # wallet for the feed's my_pos->bets safety net, cond so check_book can
            # rebuild the conds map (an orphan without it can never settle)
            self.state["my_pos"][token] = {
                "shares": res["filled_shares"], "cost": spent,
                "title": title, "outcome": outcome, "event": event,
                "wallet": wallet, "cond": cond}
        tag = "[PAPER]" if not self.ex.live else "[LIVE]"
        self.alert(
            f"{kind} {label} — {tag} buy {res['filled_shares']:.1f} "
            f"@ {res['price']:.3f}  (${spent:.2f})",
            discord_text=(f"🟢 **{kind.strip()}** {tag}\n{label}\n"
                          f"buy {res['filled_shares']:.0f} @ {res['price']:.3f} "
                          f"= **${spent:.2f}**"))

    def _handle_their_sell(self, token, their_size, their_prev, label):
        mine = self.state["my_pos"].get(token)
        if not mine:
            return  # we don't hold it
        if (self.cfg.get("follow") or {}).get("exit_mode", "mirror") == "hold":
            # #21 dark path: never mirror their exits — positions ride to
            # resolution. Every ignored sell is logged so the mirror-exit
            # counterfactual stays exactly computable forward (the
            # pre-registered verdict's control).
            frac = 1.0 if their_prev <= 0 else min(1.0, their_size / their_prev)
            self.log(f"EXIT {label} — HOLD-THROUGH (exit_mode=hold, "
                     f"their frac {frac:.2f})")
            if self.on_ignored_exit:
                self.on_ignored_exit({
                    "ts": round(time.time(), 1), "token": str(token),
                    "their_size": their_size, "their_prev": their_prev,
                    "frac": round(frac, 4),
                    "shares_held": round(mine.get("shares", 0), 4),
                    "cost_held": round(mine.get("cost", 0), 2),
                    "title": (mine.get("title") or "")[:80],
                    "outcome": mine.get("outcome"),
                    "wallet": mine.get("wallet", "")})
            return
        frac = 1.0 if their_prev <= 0 else min(1.0, their_size / their_prev)
        sell_shares = min(mine["shares"], mine["shares"] * frac)
        if sell_shares <= 0:
            return
        price = self._live_price(token, "sell")
        if price is None:
            return
        # band-guarded mirroring (2026-07-23, #21 successor): the sell-band
        # study (research/sell_mirror_study.py) showed mirrored exits LOSE
        # in every band below 90c (exits gave up value; <30c +$79/sell left
        # on the table) and SAVE money at >=90c (near-resolution
        # profit-taking, n=68 -$0.85/sell). Sells below the floor are
        # ignored-and-ledgered exactly like hold mode, so the
        # counterfactual stream keeps accruing.
        min_p = (self.cfg.get("follow") or {}).get("mirror_sell_min_p", 0.0)
        if price < min_p:
            self.log(f"EXIT {label} — BAND-SKIP (sell {price:.2f} < "
                     f"{min_p:.2f} floor, their frac {frac:.2f})")
            if self.on_ignored_exit:
                self.on_ignored_exit({
                    "ts": round(time.time(), 1), "token": str(token),
                    "their_size": their_size, "their_prev": their_prev,
                    "frac": round(frac, 4), "reason": "band",
                    "sell_px": round(price, 4),
                    "shares_held": round(mine.get("shares", 0), 4),
                    "cost_held": round(mine.get("cost", 0), 2),
                    "title": (mine.get("title") or "")[:80],
                    "outcome": mine.get("outcome"),
                    "wallet": mine.get("wallet", "")})
            return
        if any(po.get("token") == token
               for po in self.state.get("pending_orders", [])):
            self.log(f"EXIT {label} — skip (in-play hold already pending on "
                     "this token; resolver owns it)")
            return
        res = self.ex.sell(token, sell_shares, price, {})
        if not res["ok"]:
            if res.get("pending"):             # in-play hold — resolver adopts
                self.state.setdefault("pending_orders", []).append({
                    **res["pending"], "token": token, "side": "SELL",
                    "wallet": mine.get("wallet", ""), "title": mine.get("title", ""),
                    "outcome": mine.get("outcome", ""), "event": mine.get("event"),
                    "cond": mine.get("cond"), "their_price": price,
                    "their_ts": None, "price": price, "is_add": False,
                    "stake": sell_shares * price, "ts": time.time(), "ttl_s": 600})
                self.log(f"EXIT {label} — PENDING (in-play hold)")
                self.persist()
                return
            # LIVE_ROLLOUT 1.6 (built 2026-07-10): a failed mirror-exit must
            # not silently ride to resolution — queue a bounded retry; the
            # heartbeat re-attempts each tick and alerts ⚠ EXIT STUCK when
            # exhausted. One entry per token; a repeat failure refreshes it.
            retries = self.state.setdefault("exit_retries", [])
            for r in retries:
                if r["token"] == token:
                    r["shares"] = max(r["shares"], sell_shares)
                    break
            else:
                retries.append({"token": token, "shares": sell_shares,
                                "label": label, "attempts": 0,
                                "ts": int(time.time())})
            self.log(f"EXIT {label} — ORDER FAILED: {str(res.get('resp'))[:80]}"
                     " · queued for retry")
            self.persist()
            return
        proceeds = res["filled_shares"] * res["price"]
        # reduce position; release cost proportionally
        sold_frac = res["filled_shares"] / mine["shares"] if mine["shares"] else 1
        mine["cost"] *= (1 - sold_frac)
        mine["shares"] -= res["filled_shares"]
        tag = "[PAPER]" if not self.ex.live else "[LIVE]"
        verb = "EXIT" if frac >= 0.999 else "TRIM"
        self.alert(
            f"{verb} {label} — {tag} sell {res['filled_shares']:.1f} "
            f"@ {res['price']:.3f}  (${proceeds:.2f})",
            discord_text=(f"🔴 **{verb}** {tag}\n{label}\n"
                          f"sell {res['filled_shares']:.0f} @ {res['price']:.3f} "
                          f"= **${proceeds:.2f}**"))
        if mine["shares"] <= 0.01:
            del self.state["my_pos"][token]

    # -- seed their current positions so exits mirror proportionally --
    def seed_wallet(self, wallet):
        if wallet in self.state["seeded"]:
            return
        self.state["their_pos"][wallet] = their_positions(wallet)
        self.state["seed_tokens"][wallet] = list(self.state["their_pos"][wallet])
        self.state["seeded"].append(wallet)
        n = len(self.state["their_pos"][wallet])
        self.log(f"seeded {wallet[:10]}… with {n} existing positions "
                 f"(won't be copied as new entries)")

    # -- one polling pass over every watched wallet --

# ── cli ──────────────────────────────────────────────────────────────────

def confirm_live(cfg):
    print("\n" + "=" * 64)
    print("  LIVE MODE — this will place REAL orders with REAL money.")
    mt, dc = cfg['risk']['max_trade_usd'], cfg['risk']['daily_spend_cap_usd']
    print(f"  Bankroll ${cfg['bankroll_usd']:.2f} · {cfg['bankroll_pct']:.1%}/entry"
          f" · max {'off' if mt >= 1e5 else '$%.0f' % mt}/trade"
          f" · daily cap {'off' if dc >= 1e5 else '$%.0f' % dc}")
    print(f"  Watching {len(cfg['watchlist'])} wallets.")
    print("=" * 64)
    # Headless arm (Fly live worker): the USER types the exact phrase into
    # `flyctl secrets set LIVE_CONFIRM="…"` — still a human checkpoint, never
    # baked into config or code (LIVE_ROLLOUT rule 0.7). Known property: while
    # the secret stays set, restarts RE-ARM automatically (desired during the
    # days-long Phase 5 matrix); `flyctl secrets unset LIVE_CONFIRM` disarms
    # at the next boot, and any wrong value aborts instead of prompting.
    env_phrase = os.environ.get("LIVE_CONFIRM")
    if env_phrase is not None:
        if env_phrase.strip() == CONFIRM_PHRASE:
            print("confirmed via LIVE_CONFIRM env — armed.")
            return
        sys.exit("Aborted — LIVE_CONFIRM is set but does not match the phrase.")
    typed = input(f'Type "{CONFIRM_PHRASE}" to proceed (anything else aborts): ')
    if typed.strip() != CONFIRM_PHRASE:
        sys.exit("Aborted — not confirmed.")


