mirror of
https://github.com/jaxperro/winning-wallet-finder.git
synced 2026-07-27 15:57:47 +00:00
54f4c511ab
Sell-band evidence: every band <90c LOSES by mirroring (<30c +$79/sell given up, 50-70c +$7.29); >=90c SAVES (-$0.85/sell, n=68). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
850 lines
43 KiB
Python
850 lines
43 KiB
Python
#!/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"
|
||
|
||
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)")
|
||
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.")
|
||
|
||
|