feat(swaps): rank candidates on an interpolated venue cost curve - #187
Draft
haydenshively wants to merge 9 commits into
Draft
feat(swaps): rank candidates on an interpolated venue cost curve#187haydenshively wants to merge 9 commits into
haydenshively wants to merge 9 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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