From 6e09e7bb71414417acd554d0a2ca1b98815688dc Mon Sep 17 00:00:00 2001 From: jaxperro Date: Thu, 2 Jul 2026 23:15:27 -0400 Subject: [PATCH] CRITICAL settle fix: CLOB winner=False means unresolved, not lost The CLOB reports winner=False on EVERY token of an unresolved market; only a True winner anywhere means resolved. resolution_price treated False as "lost", so the 60s poller settled live in-play positions as instant losses 1-3 min after entry (June's ~2h Actions cadence masked it - markets had genuinely resolved by the next check). Same semantics fixed in _market's resolved-cache gate and validate_timing._clob_winner (unresolved held bets were counted as losses, biasing copy_pnl selection downward). Also: _record_lag now AGGREGATES fills per token (ADD used to overwrite the display record, showing one fill's cost against the whole position's P&L - the Cost $20.84 vs P&L -$57.30 confusion). State repaired: the four July bets all actually WON on-chain (verified by token winner flags) - settled correctly, +$225.40 proceeds restored, book $1,045.19 (+45.19 realized, 4W/0L). Co-Authored-By: Claude Fable 5 --- copybot.py | 55 ++++++++++++++++++++++++++++------------- copybot_state.json | 40 +++++++++++++++--------------- live/validate_timing.py | 13 +++++++--- 3 files changed, 68 insertions(+), 40 deletions(-) diff --git a/copybot.py b/copybot.py index c7755534..3f3ff335 100644 --- a/copybot.py +++ b/copybot.py @@ -110,8 +110,10 @@ def _market(cond): m = json.loads(r.read().decode()) or {} except Exception: return None - # only cache a resolved market (it won't change); re-check live ones each pass - if any(t.get("winner") in (True, False) for t in (m.get("tokens") or [])): + # only cache a RESOLVED market (it won't change); re-check live ones each pass. + # CLOB semantics: unresolved markets report winner=False on EVERY token — + # resolution flips exactly one to True. Only a True winner means resolved. + if any(t.get("winner") is True for t in (m.get("tokens") or [])): with _MKT_LOCK: _MKT_CACHE[cond] = m return m @@ -132,14 +134,21 @@ def market_neg_risk(cond): def resolution_price(token_id, cond, outcome=None): """Settled price of our held token: 1.0 if it won, 0.0 if it lost, None if the market hasn't resolved yet. Matches by outcome first (as the dashboard does), - then by token_id.""" + then by token_id. + + CRITICAL semantics: the CLOB reports winner=False on EVERY token of an + UNRESOLVED market — False alone means "not yet", not "lost". A market is + resolved only once some token's winner is True. Treating False as lost made + the bot settle live in-play markets as instant losses minutes after entry + (2026-07-02: four winning bets booked as -$180 of losses).""" toks = market_tokens(cond) if not toks: return None + if not any(t.get("winner") is True for t in toks): + return None # nobody has won -> not resolved def winp(t): - w = t.get("winner") - return 1.0 if w is True else 0.0 if w is False else None + return 1.0 if t.get("winner") is True else 0.0 if outcome is not None: for t in toks: @@ -345,18 +354,30 @@ class Copybot: lag["n"] += 1 lag["sum_s"] += detect_s lag["sum_slip_pct"] += slip_pct - # record the placed bet for the live dashboard feed - self.engine.state.setdefault("bets", {})[fill["token"]] = { - "token": fill["token"], "wallet": wallet, - "name": self.names.get(wallet.lower(), wallet[:10]), - "outcome": t.get("outcome"), "title": (t.get("title") or "")[:90], - "their_price": round(their_p, 4), "my_price": round(my_p, 4), - "slippage_pct": round(slip_pct, 4), - "shares": round(fill["shares"], 2), "cost": round(fill["shares"] * my_p, 2), - "fee": fill.get("fee", 0), - "opened": int(their_ts or now), "status": "open", - "exit_price": None, "pnl": None, "settled": None, - } + # record the placed bet for the live dashboard feed. AGGREGATE across + # fills: an ADD to an existing open position must accumulate shares/ + # cost/fees, not overwrite the record with just the last fill — that + # made Cost show one fill while P&L settled the whole position. + bets = self.engine.state.setdefault("bets", {}) + prev = bets.get(fill["token"]) + if prev and prev.get("status") == "open": + sh = prev["shares"] + fill["shares"] + cost = prev["cost"] + fill["shares"] * my_p + prev.update(shares=round(sh, 2), cost=round(cost, 2), + my_price=round(cost / sh, 4) if sh else prev["my_price"], + fee=round((prev.get("fee") or 0) + fill.get("fee", 0), 4)) + else: + bets[fill["token"]] = { + "token": fill["token"], "wallet": wallet, + "name": self.names.get(wallet.lower(), wallet[:10]), + "outcome": t.get("outcome"), "title": (t.get("title") or "")[:90], + "their_price": round(their_p, 4), "my_price": round(my_p, 4), + "slippage_pct": round(slip_pct, 4), + "shares": round(fill["shares"], 2), "cost": round(fill["shares"] * my_p, 2), + "fee": fill.get("fee", 0), + "opened": int(their_ts or now), "status": "open", + "exit_price": None, "pnl": None, "settled": None, + } log(f" ↳ lag {('%.0fs' % detect_s) if detect_s is not None else '?'} · " f"their {their_p:.3f} → mine {my_p:.3f} ({slip_pct:+.1%} slippage)") diff --git a/copybot_state.json b/copybot_state.json index b5146f05..b13ea39b 100644 --- a/copybot_state.json +++ b/copybot_state.json @@ -978,7 +978,7 @@ "0x41558102a796ba971c7567cad41c307e59f8fa41" ], "conds": {}, - "cash": 819.787881884037, + "cash": 1045.19, "lag": { "n": 5, "sum_s": 239.17888164520264, @@ -999,10 +999,10 @@ "cost": 50.0, "fee": 0.69, "opened": 1783001516, - "status": "lost", - "exit_price": 0.0, - "pnl": -50.69, - "settled": 1783001640 + "status": "won", + "exit_price": 1.0, + "pnl": 41.9, + "settled": 1783048513 }, "28393949293851968084595287701797923567435527099133056404275480596406503297469": { "token": "28393949293851968084595287701797923567435527099133056404275480596406503297469", @@ -1017,10 +1017,10 @@ "cost": 37.97, "fee": 0.0513, "opened": 1783034410, - "status": "lost", - "exit_price": 0.0, - "pnl": -38.02, - "settled": 1783034498 + "status": "won", + "exit_price": 1.0, + "pnl": 1.74, + "settled": 1783048513 }, "12184589112253099646841219091162361978388293103010365931849641794603228859112": { "token": "12184589112253099646841219091162361978388293103010365931849641794603228859112", @@ -1031,14 +1031,14 @@ "their_price": 0.99, "my_price": 0.99, "slippage_pct": 0.0, - "shares": 21.05, - "cost": 20.84, - "fee": 0.0063, + "shares": 57.87, + "cost": 57.29, + "fee": 0.0172, "opened": 1783039844, - "status": "lost", - "exit_price": 0.0, - "pnl": -57.3, - "settled": 1783039964 + "status": "won", + "exit_price": 1.0, + "pnl": 0.56, + "settled": 1783048513 }, "94170053339927453082717035943543731636274553439076639716988060790503681603673": { "token": "94170053339927453082717035943543731636274553439076639716988060790503681603673", @@ -1053,10 +1053,10 @@ "cost": 34.16, "fee": 0.0297, "opened": 1783040003, - "status": "lost", - "exit_price": 0.0, - "pnl": -34.19, - "settled": 1783040090 + "status": "won", + "exit_price": 1.0, + "pnl": 0.99, + "settled": 1783048513 } }, "feed_sig": "039a4619e46287904585dd83d0f3bb01", diff --git a/live/validate_timing.py b/live/validate_timing.py index acb3bf3b..df96ad38 100644 --- a/live/validate_timing.py +++ b/live/validate_timing.py @@ -42,15 +42,22 @@ _CLOB = {} # conditionId -> {token_id: winner-price 1/0/None} def _clob_winner(cond, token): """Authoritative resolution for a token: 1 if it won, 0 if it lost, None if the - market hasn't resolved. Matched by token_id (exact, no outcome-name guessing).""" + market hasn't resolved. Matched by token_id (exact, no outcome-name guessing). + + NB: the CLOB reports winner=False on EVERY token of an UNRESOLVED market — + only a present True winner means resolved. Treating False as "lost" counted + every unresolved held bet as a loss, biasing copy_pnl (the selection metric) + downward.""" if cond not in _CLOB: try: req = urllib.request.Request("https://clob.polymarket.com/markets/" + cond, headers={"User-Agent": "Mozilla/5.0"}) m = json.loads(urllib.request.urlopen(req, timeout=20, context=_SSL).read()) + toks = m.get("tokens") or [] + resolved = any(t.get("winner") is True for t in toks) _CLOB[cond] = {str(t.get("token_id")): - (1 if t.get("winner") is True else 0 if t.get("winner") is False else None) - for t in (m.get("tokens") or [])} + ((1 if t.get("winner") is True else 0) if resolved else None) + for t in toks} except Exception: _CLOB[cond] = {} return _CLOB[cond].get(str(token))