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 <noreply@anthropic.com>
This commit is contained in:
jaxperro
2026-07-02 23:15:27 -04:00
parent 2c59c2994b
commit 6e09e7bb71
3 changed files with 68 additions and 40 deletions
+38 -17
View File
@@ -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)")
+20 -20
View File
@@ -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",
+10 -3
View File
@@ -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))