feat(swaps): rank candidates on an interpolated venue cost curve - #187
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Fix the select() return contract before the implementation lands so the quoting and tick consumers can be written against a stable shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rungs become USD decades ($0.01–$100k) converted per-collateral against an injected price source, the cache stores per-venue RATES (no oracle) so one per-pair curve serves every market on that pair, and select() log-linearly interpolates it — clamping and flagging `clamped` rather than extrapolating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
costBps stays floored at 0 so no scorer can treat a venue beating the oracle as a bonus, but the signed value is what identifies a stale oracle -- the signal the maturity's first-minute readings turned on -- so carry it as costBpsRaw rather than discarding it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry period is set by HTTP calls per candidate, not candidates, so the tick needs the real number: venue fall-through and the floor re-derive each multiply it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The firm quotes dominate the venue HTTP budget, and the tick's serial await chain turns calls-per-candidate directly into retry period. Two multipliers went away. The min-out denominator is now predicted from the probe curve instead of the oracle reference, so the first pass usually encodes a floor at or above break-even and the second pass never runs. The prediction is biased down: an aggregator encodes `quote · floor / denominator`, so a denominator under the venue's real quote overshoots the floor (safe) while one over it undershoots, where `clearsFloor` refuses the quote and the old second pass runs unchanged. The postcondition is untouched — the pass count only ever sat beneath it. A curve that ranked every enabled venue on unclamped rungs already names the winner, so the venue walk stops there. Every other curve state (cold, incomplete, clamped) fails open to the full fall-through, keeping the pre-curve guarantee that a mis-ranked probe costs a fall-through and not a lost route. `select.ok` now carries the curve's interpolated cost beside the same figure off the quote the venue returned, which is the only way to tell whether the probe (`/price`, no taker, no slippage) predicts the firm quote well enough to tighten the cap further. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The curve ranks output, so a bad_route or floor_unmet on the winner says all there is to say and the walk stops. A quote_failed does not: capping there would lose a fill a runner-up could have made. Size the prediction margin for the asymmetry too. Undersized only costs the second pass; oversized tightens the encoded floor above break-even, and a min-out shortfall already rejected 153 of 167 sends against a ~20 bps incentive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sizing now returns every (slot, mode) candidate uncapped. The tick warms the probe curve for the pairs its sized candidates actually sell through (a new async phase A.5, deduplicated per pair), charges each candidate its interpolated route cost, ranks on surplus minus that cost, and only then truncates to MAX_PLAN_CANDIDATES_PER_POSITION - so the net winner is no longer discarded before it is compared. A swap-free candidate costs zero by construction and is still never truncated away. Cold, clamped or unpriced curves fail open per position: gross-surplus ordering and no new cutoff. Phase B keeps a bounded fall-through (MAX_PRESELECTED_CANDIDATES_PER_POSITION) that reserves the best swap-free candidate and is consumed only by candidates that actually spend work; drops are counted as preselectSkipped and join the per-candidate identity. tick.end also carries the tick's summed firmCalls (absent = unknown, not zero) and durationMs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both TIBs land here: they are one analysis behind two decisions and cross-reference each other throughout, and Status: Proposed is how a decision recorded ahead of its implementation is marked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Address the review of the curve-quoting work. The min-out prediction was only bounded on one side. `clearsFloor` catches a prediction that lands the encoded floor under break-even, but the overshoot factor is `realQuote / prediction`, unbounded in the curve's pessimism: a curve 0.5% low encoded a floor 62 bps above break-even, three times the whole post-maturity incentive, converting fundable fills into send-time reverts. The postcondition is now symmetric — past twice the prediction margin the second pass re-derives against the venue's real quote — and an overshooting first quote is kept when that re-quote declines or fails, since it was already usable. A failed re-quote was also reported as `floor_unmet`, which a trusted curve is entitled to stop its venue walk on, so a transient 429 on the SECOND pass lost a liquidation the runner-up could have filled. It now reports the transport reason it is, and the walk continues. The prediction consumes the curve's absolute level, which decays with cache age, so it carries its own age ceiling independent of `PROBE_STALE_MS`. That protects midnight against a stale cache and blue — whose 10-minute TTL was justified on consuming ordering only, which the prediction work invalidated — without multiplying blue's probe traffic. Probe cost: a cold sweep was serial across venues at ~24 s per pair despite per-venue token buckets, and it was awaited before any quote ran. Venues now sweep concurrently (~8 s), the warm is started rather than awaited so it warms for the next tick, and `refresh` dedupes an in-flight sweep so nothing double-probes or waits behind one. Removing the candidate cap from sizing had left the fan-out unbounded, so a second, looser gross cap bounds which candidates get a route resolved at all; bad-debt write-offs, which phase B never quotes, are no longer routed or warmed. Also: count firm calls at the fetch seam so transport retries are not hidden, serve `usdPriceOf` off the price snapshot instead of an unmemoized `decimals` read that could leave a pair cold, clamp a degenerate interpolation bracket instead of reporting confidence, and pin the two USD scales against each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A regression sweep over #184/#187/#188/#189 found four behaviors the stack changed without meaning to. None are new functionality; each restores what the bots did on main. Detection-only blue could broadcast. The no-venues gate moved to AFTER unwrap resolution and the swap-free branch lost its `steps.length > 0` guard, so a venue-less deployment returned `kind: 'swap'` — which the tick takes straight to simulate+submit. docker-compose defaults Robinhood (4663) to ALLOW_DETECTION_ONLY with a funded key, documented as skipping every routed liquidation. `swapFreeWithoutVenues` (default false) restores the immediate refusal; midnight opts in, because its loan-as-collateral slots need no route and ALLOW_BAD_DEBT_ONLY is a supported posture there. That same reorder downgraded a transient unwrapper RPC failure from `no_config` (skip) to `failed` (arms backoff), pushing a deliberately unarmed deployment into a suppression state machine it never entered. Refusing before the unwrap chain fixes both at once, and spends no reads doing it. A send REJECTION now arms backoff even when a sibling execution-reverted. Both sets are keyed by position, so `backoffExempt` was cancelling the backoff a broken nonce/funds/RPC send earned; the position then re-sent every block while the send machinery was still broken. Phase A.5 no longer resolves routes for suppressed positions. It ran ahead of the cooldown/backoff gates, so a backed-off position spent an uncached read per candidate per tick — breaking backoff's contract that it bounds API and RPC usage under a backlog, and putting that latency in front of the first send of a maturity burst. Each of the 8 new tests was verified to fail against the pre-fix source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b47a796 to
3d9e79e
Compare
There was a problem hiding this comment.
Devin Review found 4 potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d9e79ef77
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Triaged all 20 Codex/Devin findings across #184, #187, #188, #189 and #192. Fixed: - the venue-less swap-free exception admitted unwrap-only plans, so an ALLOW_BAD_DEBT_ONLY deployment could broadcast an asset-moving liquidation - candidate ranking trusted stale and incomplete curves that quoting refuses; both now share one exported `curveIsTrusted` - a Pendle PT resolved its unwrap chain twice per tick, both hosted API calls; new `previewTokenOut` seam answers phase A.5 from cache - phase A.5 resolved routes serially, in discovery order - a sibling's `no_route` suppressed a position whose other candidate was `floor_unmet`, which is meant to retry every block as the LIF ramps - the wall-clock cooldown verdict could flip mid-tick, leaving a candidate quoted but unpriced - revert-streak state never expired, so a reused label reported false crossings - `tx.submit_failed` carried no candidate discriminator - docs: the swap-free "iff" guarantee, `selectorConstant`, two README rows Declined, with reasoning for the threads: narrowing the backoff exemption to post-maturity plans (a regression — the sets are per-position while the mode is per-candidate), `stopAfterWinner`, and the sub-1e-18 rate truncation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes BOTS-91. Stacked on #184 (
worktree-midnight-loan-as-collateral) — review that first; this PR's own diff isworktree-midnight-loan-as-collateral...feat/venue-cost-curve.Why
From the 2026-08-28 15:00 UTC maturity (BetterStack source
2607569):PROBE_LADDERwas0.01,0.1,1,10,100in whole collateral tokens — $800–$8M for cbBTC. Real seize sizes ranp50 0.0000379 / max 0.0504cbBTC, so 832 of 926 (90%) fell below the bottom rung (median seize ≈ $3). Everything snapped to bucket 0, and the top rung reliably loggedprobe.venue_error.rankByUsdSurplusordered on pure-oracleplanSurplus.venues × 2calls per candidate. Because the tick is a serialawaitchain, calls-per-candidate is a position's retry period — so this is a latency fix, not just a quota one.createVenueSelectoralready probed every venue at every rung and cached it;select()threw the curve away.What
PROBE_LADDERkeeps its name.(chainId, collateral, loan)and is shared across every market on that pair; cost in bps is derived per candidate at read time from its own oracle. Midnight's TTL drops to 45 s.clampedflag, and a fail-open rule: a cold, incomplete or clamped curve reverts to gross ordering with full venue fall-through, preserving the pre-curve guarantee that a bad probe costs at most a fall-through.capCandidatestruncated on gross surplus before ranking could reorder, so the true net winner could already be gone. Sizing now returns uncapped; the tick enriches, ranks, then caps — keeping the swap-free reservation.maintainwas rejected as a home.firmCallscounted at the fetch seam, andcurveCostBps/firmQuoteCostBpsonselect.okto measure probe fidelity (currently unmeasured).Plus two TIBs, a supersession block on TIB-2026-07-09 (its fixed ladder, its gross-ranking-only non-goal, and its "a mis-ranked venue costs at most a fall-through" assumption), and
docs/INDEX.mdentries.Review notes
Post-review corrections worth a second look:
Q/Q̂, unbounded in the curve's pessimism — a curve only 0.5% pessimistic produced a floor 61.6 bps above break-even, against a ~20 bps incentive.clearsFlooris one-sided, so a symmetric postcondition now spends the second pass when the first-pass floor overshoots by more than the bound.floor_unmetis also returned when the second-pass HTTP call fails, so capping on it lost liquidations a runner-up would have won. That path now reportsquote_failedand keeps falling through.MAX_PREDICTION_AGE_MSin@repo/swapsrather than by shortening blue's TTL, which protects both bots.Verification
pnpm test210 files / 2811 tests, 0 failed · lint 0 warnings · knip clean · typecheck across swaps + both liquidators · fork suite 3/3. Every new assertion verified by break-one-assertion; the overshoot bound and the transport/economic split additionally proven by source mutation.Note
pnpm --filter @repo/contracts run buildis required before bot typechecks (staledist).🤖 Generated with Claude Code