Resolves HANDOVER.md PART 2 findings M7, M8, M9 (plus the L7 cache-poisoning bug) in as_weather_algo. These were the open code-review bugs that made the dry-run’s accumulated P&L untrustworthy (see warning in weather-as-deploy-pattern-b-2026-06-23). All in selfmeasure.py + config.py + shadow_engine._measure_gated. Built TDD, verified by multi-agent adversarial review, 44 tests green on Python 3.14.
M7 — Fill realism (fill-vs-limit mismatch)
Self-measure now rejects fills above the producer’s actual limit price min(0.95, current_at_lock + PRICE_BUMP), not just the 0.95 PRICE_CAP.
limit_priceis threaded from the lock through_measure_gated→record_signal→realizable_fill.- Reject when
fill > limit_price + 1e-9. PRICE_CAP(0.95) remains the backstop whenlimit_priceisNone(legacy records).- Effect: tightens the fill rate to an honest number instead of accepting fills the producer would never have placed.
M8 — P&L leak + void economics (non-binary-terminal leakage)
Closed-but-non-binary terminals were being settled immediately as void at the terminal mid price — wrong twice:
- A market can be
closed:truemid-dispute before finalizing to 0/1 → settling early permanently mislabels it. - A real Polymarket void REFUNDS stake (P&L ≈ 0) — it does not pay out the mid.
Fix — never infer void from price:
_resolve_payoffreturns binary1.0/0.0only when closed & binary, elseNone(pending).settleforce-settles a still-unresolved row as a stake-refund void (payoff = our_fill_price→gross_pnl = 0,net_pnl = -cost) only afterMAX_SETTLE_AGE_DAYS(default 7). Bounded delay → no infinite leak, no premature mislabel.
Also fixed (L7) multi-market cache poisoning: _RES_CACHE was slug-keyed, storing the whole event’s token map gated only on the queried token’s closed-ness. Resolving one closed bucket cached the open sibling buckets, which then leaked forever. Now keyed per (slug, token), cached only when that token is resolved; open tokens re-fetch.
M9 — Cost honesty (cost hardcoded 0)
Records both gross_pnl and net_pnl. Slippage is mode-aware:
- Live:
0is correct — the realized fill is the book ask, so spread is already in the fill. - Replay:
/prices-historyfills are midpoints (no spread) → a separateSLIPPAGE_REPLAYknob models the half-spread.
New config.py knobs: POLYMARKET_FEE, SLIPPAGE_LIVE, SLIPPAGE_REPLAY, MAX_SETTLE_AGE_DAYS.
Reusable lessons
Polymarket P&L / resolution gotchas
- A Polymarket void/invalid market is a STAKE REFUND (P&L ≈ 0), not a payout at the residual mid. Never compute void P&L as
term - fill.- Don’t infer resolution/void from
outcomePricesalone —closed:truecan coexist with mid prices during the dispute/settlement window.- Multi-market events: a per-event cache gated on one token’s state can poison sibling tokens — key caches per
(event, token).- Replay backtest fills from
/prices-historyare midpoints with no spread; live fills are book asks (spread included). Cost models must be mode-aware.
Open follow-up
The void-age clock uses
lock_ts(our_entry_ts) — harmless on the live--looppath, but switch tomax(record_ts, entry_ts)if backfill / replay-of-old-locks is ever added.
2026-06-24 — Live --loop crash: %Z strptime can’t parse abbreviated zone names (fix @ 7939da2)
Found in production by @deploy in babylon weather-shadow #442: the live pmv2-weather-as-algo --loop container spammed loop_error: time data '2026-06-23 15:56 PDT' does not match format '%Y-%m-%d %H:%M %Z' (also JST, CST).
Root cause — a strftime→strptime round-trip through %Z:
rehearsestrftime’s the station-local lock time INTOtrace["lock_local_time"](e.g."2026-06-23 15:56 PDT").shadow_engine.lock_from_tracethen re-parsed that string withdatetime.strptime(s, "%Y-%m-%d %H:%M %Z").- Python’s
%Zstrptime cannot parse abbreviated zone names (PDT/JST/CST) — it only acceptsUTC/GMT/numeric offsets. →ValueErroron every locked city. - The exception propagated out of
scan()and aborted the entire scan tick → on any tick with a locked city, the live service recorded nothing (only no-op / error iterations).
Fix — don’t round-trip a datetime through a %Z string. Derive the local datetime directly from data already in hand:
datetime.fromtimestamp(trace["lock_ts"], tz=timezone.utc).astimezone(ZoneInfo(st["tz"]))The lock_local_time strftime field stays as a human-readable trace value (strftime is fine; only the strptime parse-back was broken). build_signal uses that datetime only for an HH:MM display in its reason string, so a tz-aware local datetime is correct.
Python
%Zstrptime can't parse abbreviated zone names
datetime.strptimewith%Zonly parsesUTC/GMT/numeric offsets — NOT abbreviated names likePDT/JST/CST. Never round-trip a localized datetime throughstrftime('%Z')→strptime('%Z'). Carry the epoch + IANA zone and reconstruct:fromtimestamp(ts, utc).astimezone(ZoneInfo(zone)).
Test-coverage lesson — don't mock the function under test
Every existing test mocked
lock_from_traceitself, so thestrftime→strptimeround-trip inside it was never exercised end-to-end → the bug shipped. When you mock the function under test, its real internal path goes uncovered. Add at least one test that runs the real function across the risky boundary (here: an abbreviated-zone tz). Regression tests now cover the JST + PDT paths.
Deploy-mode-OFF-first discipline paid off
The forward dry-run deployment (mode OFF, zero economic risk) surfaced a real bug before any capital was at risk — exactly the point of running the dry-run first. See [[weather-as-deploy-pattern-b-2026-06-23#update-2026-06-23—continuous-service---loop|Update 2026-06-23 — Continuous Service (
--loop)]].
Status: full suite 46 tests green on Python 3.14; fix pushed main @ 7939da2, new image in GAR, awaiting @deploy’s container bump.