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_price is threaded from the lock through _measure_gatedrecord_signalrealizable_fill.
  • Reject when fill > limit_price + 1e-9.
  • PRICE_CAP (0.95) remains the backstop when limit_price is None (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:

  1. A market can be closed:true mid-dispute before finalizing to 0/1 → settling early permanently mislabels it.
  2. A real Polymarket void REFUNDS stake (P&L ≈ 0) — it does not pay out the mid.

Fix — never infer void from price:

  • _resolve_payoff returns binary 1.0/0.0 only when closed & binary, else None (pending).
  • settle force-settles a still-unresolved row as a stake-refund void (payoff = our_fill_pricegross_pnl = 0, net_pnl = -cost) only after MAX_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: 0 is correct — the realized fill is the book ask, so spread is already in the fill.
  • Replay: /prices-history fills are midpoints (no spread) → a separate SLIPPAGE_REPLAY knob 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 outcomePrices aloneclosed:true can 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-history are 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 --loop path, but switch to max(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:

  • rehearse strftime’s the station-local lock time INTO trace["lock_local_time"] (e.g. "2026-06-23 15:56 PDT").
  • shadow_engine.lock_from_trace then re-parsed that string with datetime.strptime(s, "%Y-%m-%d %H:%M %Z").
  • Python’s %Z strptime cannot parse abbreviated zone names (PDT/JST/CST) — it only accepts UTC/GMT/numeric offsets. → ValueError on 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 %Z strptime can't parse abbreviated zone names

datetime.strptime with %Z only parses UTC/GMT/numeric offsets — NOT abbreviated names like PDT/JST/CST. Never round-trip a localized datetime through strftime('%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_trace itself, so the strftimestrptime round-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.